Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion multiace/klipper/extras/ace.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,9 @@ class AceException(Exception):
# clean macro before restoring the print position - no extra travel.
BG_PICK_WIPE = True

# min_extrude_temp of the U1; a swap-head target below it is no print temp.
SWAP_MIN_EXTRUDE_TEMP = 170

# V2 only: cause-agnostic back-off for the FA re-arm churn. When a
# slot's re-arm never STICKS (the device keeps disarming because nothing
# actually feeds - filament cut UPSTREAM of the toolhead while a remnant still
Expand Down Expand Up @@ -773,6 +776,10 @@ def __init__(self, config):
self._protocols = {}
self._ace_path_protocol = {}
self._ace_models = {}
# Heater intent observer (see _install_heat_observer).
self._ext_heat_req = {}
self._own_heat_depth = 0
self._orig_set_temperature = None
self.baud = config.getint('baud', 0, minval=0)
self._ace_devices = []
self._active_device_index = 0
Expand Down Expand Up @@ -1587,6 +1594,9 @@ def _parse_idx_list(key):
# reference for the cool forward-probe temp (see filament_feed_ace
# _swap_probe_temp). 0 = no live print (idle/test) -> probe floor.
self._swap_probe_ref_temp = 0
# Last print target (>= min extrude) a swap found on each head; the
# fallback when a swap starts on a head a pause left at target 0.
self._swap_print_temp = {}

# Engine event/observability state (see docs/ENGINE_API.md section 5).
# swap_phase gives finer resolution than swap_in_progress; event_seq
Expand Down Expand Up @@ -3183,6 +3193,58 @@ def _reactor_stall_watchdog(self, eventtime):
self._watchdog_next = eventtime + REACTOR_WATCHDOG_INTERVAL
return self._watchdog_next

# Heater intent observer. Every pheaters.set_temperature that does not
# come through heat_set_own() is a foreign request (slicer M104/M109,
# INNER macros, the display webhook) and is remembered per heater as
# (temp, time). The background swap engine restores the newest foreign
# request when it hands a head's heater back instead of cooling to 0:
# a slicer M109 that arrives while a bg op holds the heater would
# otherwise wait forever on a target of 0 (PID check_busy never
# settles), and a value-equal request (M109 S220 against our own 220)
# is invisible to any comparison of targets - only the call tells.
def _install_heat_observer(self):
if self._orig_set_temperature is not None:
return
pheaters = self.printer.lookup_object('heaters', None)
orig = getattr(pheaters, 'set_temperature', None)
if orig is None:
return
self._orig_set_temperature = orig
pheaters.set_temperature = self._observed_set_temperature
logging.info('[multiACE] heater intent observer installed')

def _observed_set_temperature(self, heater, temp, wait=False):
if not self._own_heat_depth:
try:
self._ext_heat_req[heater.get_name()] = (
float(temp), self.reactor.monotonic())
except Exception:
pass
return self._orig_set_temperature(heater, temp, wait)

def heat_set_own(self, pheaters, heater, temp):
"""set_temperature by multiACE's own background engine: recorded as
ours, not as a foreign request. Never waits: _own_heat_depth is one
counter for the whole process, not per greenlet, so a blocking
set_temperature(wait=True) would yield the reactor and book a
slicer M109 arriving during that wait as ours."""
self._own_heat_depth += 1
try:
return pheaters.set_temperature(heater, temp, False)
finally:
self._own_heat_depth -= 1

def heat_foreign_since(self, heater, since):
"""(temp, time) of the newest foreign request for this heater at or
after `since` (reactor clock), else None."""
try:
req = self._ext_heat_req.get(heater.get_name())
except Exception:
return None
if req is None or req[1] < since:
return None
return req

def _handle_ready(self):
self.toolhead = self.printer.lookup_object('toolhead')

Expand All @@ -3198,6 +3260,9 @@ def _handle_ready(self):
self._install_flow_calibrator_hook()
self._install_flow_calibrate_cmd_hook()

# Heater intent observer for the background swap engine.
self._install_heat_observer()

# [diag] reactor-stall watchdog (0003-0522 Timer-too-close hunt).
if self.stall_watchdog and getattr(self, '_watchdog_timer', None) is None:
self._watchdog_next = None
Expand Down Expand Up @@ -4828,6 +4893,7 @@ def _on_print_end(self, *args):
self._auto_feed_enabled = False
self._fa_context = 'idle'
self._runout_suppress_heads = set()
self._swap_print_temp = {}
logging.info('[multiACE] Print ended - auto-feed disabled')
# Booking audit summary: what each spool consumed since its first
# booking of this print (baseline set in book_spool_use). fires once
Expand Down Expand Up @@ -20899,6 +20965,23 @@ def cmd_ACE_SWAP_HEAD(self, gcmd):
except Exception:
pass
logging.info('[multiACE] Swap: saved heater=%d (swap head)' % saved_heater_target)
# A pause can leave the swap head at target 0, and its RESUME
# replays this swap line before any slicer M104/M109. Restoring
# that 0 at the swap tail lets the print run on with a cooling
# nozzle. While printing, a target below min extrude is replaced
# by the last print target a swap saw on this head, else by the
# swap temp. The INITIAL block keeps the found target: the start
# gcode heats the head itself.
if saved_heater_target >= SWAP_MIN_EXTRUDE_TEMP:
self._swap_print_temp[head] = saved_heater_target
elif not initial_swap and self._is_actively_printing():
fallback = self._swap_print_temp.get(head) or swap_temp
logging.info('[multiACE] Swap: saved heater %d is below min '
'extrude while printing - restoring %d (%s)'
% (saved_heater_target, fallback,
'last print target' if head in
self._swap_print_temp else 'swap temp'))
saved_heater_target = fallback
# Reference for the cool forward-probe temp (material-agnostic):
# the live print target of this head, before INNER zeroes it.
self._swap_probe_ref_temp = saved_heater_target
Expand Down Expand Up @@ -21127,7 +21210,9 @@ def cmd_ACE_SWAP_HEAD(self, gcmd):
except Exception as fa_e:
logging.info('[multiACE] post-load FA re-enable failed: %s' % fa_e)

wipe_temp = saved_heater_target if saved_heater_target >= 170 else swap_temp
wipe_temp = (saved_heater_target
if saved_heater_target >= SWAP_MIN_EXTRUDE_TEMP
else swap_temp)
self.gcode.run_script_from_command('M109 S%d' % wipe_temp)
self.gcode.run_script_from_command('ROUGHLY_CLEAN_NOZZLE_WITH_DISCARD')
self.toolhead.wait_moves()
Expand Down
102 changes: 81 additions & 21 deletions multiace/klipper/extras/ace_bg_swap.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ def __init__(self, config):
self.printer = config.get_printer()
self.reactor = self.printer.get_reactor()
self.gcode = self.printer.lookup_object('gcode')
# Per-head heater claim (claim time, target found) while a bg op
# holds the heater - see _heat_claim / _release_heat.
self._heat_claims = {}
# Per-head opt-in = the HARDWARE declaration "this head's dock is
# open below" (the cold-pull purges ~60mm through it). Only listed
# heads run bg unloads; a QUIET (gcode-stamped) request for another
Expand Down Expand Up @@ -1234,6 +1237,64 @@ def _bg_dec_log(self, ace, head, slot, kind, length, span_tuple):

# -- the sequences (run in an async-callback greenlet; reactor.pause is
# allowed here and does NOT block the print) -----------------------
# Heater ownership. A bg op takes a parked head's heater over; the
# slicer may meanwhile ask for that head (standby M104, the M109 before
# its T). The engine never cools to 0 blindly: at hand-back it restores
# the newest foreign request since the claim (ace's heater intent
# observer), else the target it found at the claim, else 0. A second
# release without a claim is a no-op, so an abort after a normal
# release cannot undo the restore. Older ace.py without the observer:
# found target or 0. Print end/cancel turn the heaters off past the
# observer (turn_off_all_heaters -> heater.set_temp), so once the
# print is neither printing nor paused the intent is 0.
def _print_active(self):
try:
ps = self.printer.lookup_object('print_stats', None)
if ps is None:
return False
st = ps.get_status(self.reactor.monotonic()).get('state', '')
return st in ('printing', 'paused')
except Exception:
return False

def _heat_claim(self, head, heater):
try:
found = float(getattr(heater, 'target_temp', 0.) or 0.)
except Exception:
found = 0.
self._heat_claims[head] = (self.reactor.monotonic(), found)

def _set_heat(self, ace, pheaters, heater, temp):
own = getattr(ace, 'heat_set_own', None)
if own is not None:
return own(pheaters, heater, temp)
return pheaters.set_temperature(heater, temp)

def _release_heat(self, ace, head, heater, pheaters, why):
claim = self._heat_claims.pop(head, None)
if claim is None:
return None
since, found = claim
target, src = 0., 'off'
req = None
fn = getattr(ace, 'heat_foreign_since', None)
if fn is not None:
try:
req = fn(heater, since)
except Exception:
req = None
if req is not None:
target, src = float(req[0]), 'slicer request'
elif found > 0.:
target, src = found, 'target found at takeover'
if target > 0. and not self._print_active():
target, src = 0., 'print ended'
self._set_heat(ace, pheaters, heater, target)
if target > 0. or src == 'print ended':
self._say('head %d: heater back to %.0f (%s) after %s'
% (self._dh(head), target, src, why))
return target

def _run_unload(self, head, ace_idx, slot, temp, soft):
ace = self.printer.lookup_object('ace')
toolhead = self.printer.lookup_object('toolhead')
Expand All @@ -1259,7 +1320,8 @@ def _fail_unload(self, head, heater, pheaters, e, retract_done):
reason = str(e) or e.__class__.__name__
self.state[head] = 'FAILED:%s' % reason
try:
pheaters.set_temperature(heater, 0.)
self._release_heat(self.printer.lookup_object('ace', None),
head, heater, pheaters, 'unload abort')
except Exception:
pass
if isinstance(e, ToolheadNotClear):
Expand Down Expand Up @@ -1315,7 +1377,8 @@ def _unload_core(self, head, ace, toolhead, ext, heater, pheaters,

# 2. Heat (async target set, greenlet-poll - never blocks gcode).
self.state[head] = 'HEAT'
pheaters.set_temperature(heater, temp)
self._heat_claim(head, heater)
self._set_heat(ace, pheaters, heater, temp)
self._say('head %d: heating to %.0f' % (self._dh(head), temp))
deadline = self.reactor.monotonic() + HEAT_TIMEOUT
_retgt_said = False
Expand All @@ -1331,7 +1394,7 @@ def _unload_core(self, head, ace, toolhead, ext, heater, pheaters,
'externally (%.0f) - re-asserting %.0f'
% (self._dh(head), _tgt, temp))
_retgt_said = True
pheaters.set_temperature(heater, temp)
self._set_heat(ace, pheaters, heater, temp)
if self.reactor.monotonic() > deadline:
raise RuntimeError('heat timeout (%.0f/%.0f)'
% (cur, temp))
Expand Down Expand Up @@ -1381,7 +1444,7 @@ def _unload_core(self, head, ace, toolhead, ext, heater, pheaters,
if kind == 'temp':
# target change only, no wait (mirrors the inline
# 'temp:' token semantics = M104).
pheaters.set_temperature(heater, float(tok[1]))
self._set_heat(ace, pheaters, heater, float(tok[1]))
continue
if kind == 'waittemp':
# Set target + wait until reached, both directions. On a
Expand All @@ -1391,7 +1454,7 @@ def _unload_core(self, head, ace, toolhead, ext, heater, pheaters,
# instead of failing the whole unload for a tuning
# nicety. Pick-abort stays live via _check_docked.
c = float(tok[1])
pheaters.set_temperature(heater, c)
self._set_heat(ace, pheaters, heater, c)
_wt_deadline = self.reactor.monotonic() + 180.
while True:
self._check_docked(toolhead, ext, head)
Expand Down Expand Up @@ -1482,8 +1545,9 @@ def _unload_core(self, head, ace, toolhead, ext, heater, pheaters,
self._say('head %d: cold-pull done (%d mm confirmed reclaimed '
'at ACE)' % (self._dh(head), int(reclaimed)))

# 4. Heater off (stock does M104 S0 right after the pull).
pheaters.set_temperature(heater, 0.)
# 4. Heater back to the slicer's intent (stock does M104 S0
# right after the pull; 0 only when nobody asked for more).
self._release_heat(ace, head, heater, pheaters, 'cold-pull')

# 5. ACE retract to the SWAP length, minus what the interleaved
# cold-pull unwinds already reclaimed. The bg unload is the unload
Expand Down Expand Up @@ -1698,7 +1762,7 @@ def _run_swap(self, head, un, ld, temp, soft, anti_ooze,
self.state[head] = 'FAILED:%s' % reason
self._ace_quiesce(ace, ld[0], ld[1], 'bg load abort')
try:
pheaters.set_temperature(heater, 0.)
self._release_heat(ace, head, heater, pheaters, 'load abort')
except Exception:
pass
self._say('head %d: LOAD FAILED (%s) - the arrival swap loads '
Expand Down Expand Up @@ -1794,7 +1858,8 @@ def _load_core(self, head, ace, toolhead, ext, heater, pheaters,
# get_load_length is the padded upper bound like inline; the sensor
# cuts it short, so neither hardware rams the gears.
self.state[head] = 'LD_FEED'
pheaters.set_temperature(heater, temp)
self._heat_claim(head, heater)
self._set_heat(ace, pheaters, heater, temp)
feed_speed = ACE_UNWIND_SPEED_FALLBACK
try:
feed_speed = int(ace.get_feed_speed(ace_idx))
Expand Down Expand Up @@ -1849,13 +1914,13 @@ def _load_core(self, head, ace, toolhead, ext, heater, pheaters,
_ever_moved = True
self._dwell_fan(ace, head, False)
if feed_res == 'stale':
pheaters.set_temperature(heater, 0.)
self._release_heat(ace, head, heater, pheaters, 'stale latch')
raise RuntimeError(
'toolhead sensor of head %d reads PRESENT although the head '
'is empty (stale latch) - display/web unload it once, then '
'retry' % self._dh(head))
if feed_res != 'ok':
pheaters.set_temperature(heater, 0.)
self._release_heat(ace, head, heater, pheaters, 'feed failure')
if feed_res == 'partial' or _ever_moved:
# Filament moved but never reached the sensor: LEAVE IT IN
# THE PATH (no cleanup retract - a commanded-length pull can
Expand Down Expand Up @@ -1908,7 +1973,7 @@ def _load_core(self, head, ace, toolhead, ext, heater, pheaters,
'externally (%.0f) - re-asserting %.0f'
% (self._dh(head), _tgt, temp))
_retgt_said = True
pheaters.set_temperature(heater, temp)
self._set_heat(ace, pheaters, heater, temp)
if self.reactor.monotonic() > deadline:
raise RuntimeError('heat timeout (%.0f/%.0f)'
% (cur, temp))
Expand Down Expand Up @@ -2138,7 +2203,7 @@ def _do_press():
'STAGED at the toolhead sensor; the arrival/inline '
'load continues from there'
% (self._dh(head), str(e) or e.__class__.__name__))
pheaters.set_temperature(heater, 0.)
self._release_heat(ace, head, heater, pheaters, 'abort before grip')
self._gpio_diag(head, 'staged after abort (expected present)')
# Bookkeep the staged state: the arrival swap must SKIP its
# unload half (no head_source - it would guess a wrong slot from
Expand All @@ -2151,17 +2216,12 @@ def _do_press():
pass
raise RuntimeError(str(e) or e.__class__.__name__)

# 6. Success: assist off; cool ONLY if the target is still ours
# (a slicer preheat M104 for the arrival may have overwritten it
# mid-load - that target belongs to the print, leave it).
# 6. Success: assist off; heater back to the slicer's intent (a
# preheat for the arrival issued mid-load belongs to the print).
self._ace_send(ace, ace_idx, {
'method': 'stop_feed_assist', 'params': {'index': slot}})
ace._feed_assist_per_ace[ace_idx] = -1
try:
if abs(float(getattr(heater, 'target_temp', 0.)) - temp) < 1.:
pheaters.set_temperature(heater, 0.)
except Exception:
pheaters.set_temperature(heater, 0.)
self._release_heat(ace, head, heater, pheaters, 'bg load done')
self._load_bookkeeping(head, ace, ace_idx, slot)


Expand Down
Loading
Loading