diff --git a/multiace/klipper/extras/ace.py b/multiace/klipper/extras/ace.py index 368c8de8..e24037a6 100644 --- a/multiace/klipper/extras/ace.py +++ b/multiace/klipper/extras/ace.py @@ -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 @@ -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 @@ -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 @@ -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') @@ -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 @@ -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 @@ -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 @@ -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() diff --git a/multiace/klipper/extras/ace_bg_swap.py b/multiace/klipper/extras/ace_bg_swap.py index 700d9c1e..c2c6887e 100644 --- a/multiace/klipper/extras/ace_bg_swap.py +++ b/multiace/klipper/extras/ace_bg_swap.py @@ -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 @@ -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') @@ -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): @@ -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 @@ -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)) @@ -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 @@ -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) @@ -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 @@ -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 ' @@ -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)) @@ -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 @@ -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)) @@ -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 @@ -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) diff --git a/tests/unit/test_bg_heat_intent.py b/tests/unit/test_bg_heat_intent.py new file mode 100644 index 00000000..f723a210 --- /dev/null +++ b/tests/unit/test_bg_heat_intent.py @@ -0,0 +1,282 @@ +"""bg-swap heater ownership: the heater intent observer in ace.py and the +claim/release helpers of the bg engine, driven with the real methods on +stubs (no printer, no trapq). + +The field case (docx-tech, bg transport mode): the slicer's `M109 S220 +T` arrived while the bg cold-pull held that head's heater; step 4 +then set the target to 0 and the M109 waited forever (PID check_busy never +settles on 0). A value-equal request (M109 at the engine's own pull temp) +is invisible to any comparison of targets, so the observer records the +CALL, and the release restores the newest foreign request since the claim, +else the target found at the claim, else 0; after print end always 0. +""" +import importlib +import sys +import types + +import os + + +class H: + """Imports the real Klipper extras as a package (no printer needed).""" + EXTRAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', + 'multiace', 'klipper', 'extras') + PKG = 'multiace_bg_heat_extras' + + @staticmethod + def load_ace(): + name = H.PKG + '.ace' + if name in sys.modules: + return sys.modules[name] + pkg = types.ModuleType(H.PKG) + pkg.__path__ = [H.EXTRAS] + sys.modules[H.PKG] = pkg + return importlib.import_module(name) + + +class FakeHeater: + def __init__(self, name, target=0.): + self.name = name + self.target_temp = target + + def get_name(self): + return self.name + + +class FakePheaters: + def __init__(self): + self.calls = [] + + def set_temperature(self, heater, temp, wait=False): + heater.target_temp = float(temp) + self.calls.append((heater.get_name(), float(temp), bool(wait))) + + +class FakeReactor: + def __init__(self): + self.now = 100.0 + + def monotonic(self): + return self.now + + +class FakePrintStats: + def __init__(self, state='printing'): + self.state = state + + def get_status(self, eventtime=None): + return {'state': self.state} + + +class FakePrinter: + def __init__(self, objects): + self.objects = objects + + def lookup_object(self, name, default=None): + return self.objects.get(name, default) + + +def _bg_module(): + name = H.PKG + '.ace_bg_swap' + if name in sys.modules: + return sys.modules[name] + H.load_ace() + if 'chelper' not in sys.modules: + sys.modules['chelper'] = types.ModuleType('chelper') + fm = H.PKG + '.force_move' + if fm not in sys.modules: + # Klipper extra the engine borrows calc_move_time from; not needed + # for the heater helpers under test. + stub = types.ModuleType(fm) + stub.calc_move_time = None + sys.modules[fm] = stub + setattr(sys.modules[H.PKG], 'force_move', stub) + return importlib.import_module(name) + + +def _rig(print_state='printing', with_observer=True): + A = H.load_ace() + BG = _bg_module() + reactor = FakeReactor() + pheaters = FakePheaters() + ps = FakePrintStats(print_state) + + class Ace(object): + _install_heat_observer = A.MultiAce._install_heat_observer + _observed_set_temperature = A.MultiAce._observed_set_temperature + heat_set_own = A.MultiAce.heat_set_own + heat_foreign_since = A.MultiAce.heat_foreign_since + ace = Ace() if with_observer else object() + printer = FakePrinter({'heaters': pheaters, 'ace': ace, + 'print_stats': ps}) + if with_observer: + ace.printer = printer + ace.reactor = reactor + ace._ext_heat_req = {} + ace._own_heat_depth = 0 + ace._orig_set_temperature = None + ace._install_heat_observer() + + class Bg(object): + _heat_claim = BG.AceBgSwap._heat_claim + _set_heat = BG.AceBgSwap._set_heat + _release_heat = BG.AceBgSwap._release_heat + _print_active = BG.AceBgSwap._print_active + + def __init__(self): + self.said = [] + self._heat_claims = {} + + def _say(self, msg): + self.said.append(msg) + + def _dh(self, i): + return i + 1 + bg = Bg() + bg.printer = printer + bg.reactor = reactor + return ace, bg, pheaters, reactor, ps + + +def test_observer_records_foreign_calls_only(): + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder') + bg._set_heat(ace, ph, h, 215.) # own + assert ace.heat_foreign_since(h, 0.) is None + reactor.now = 101.0 + ph.set_temperature(h, 220.) # slicer M104/M109 + assert ace.heat_foreign_since(h, 0.) == (220., 101.0) + assert ace.heat_foreign_since(h, 101.5) is None + # the wrapper still drives the real heater + assert h.target_temp == 220. + assert ph.calls == [('extruder', 215., False), ('extruder', 220., False)] + + +def test_value_equal_m109_is_restored(): + """docx-tech class: standby 70 found, own pull temp 215, slicer M109 + at the SAME value during the op -> hand-back keeps 215, not 70, not 0.""" + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + reactor.now += 30 + ph.set_temperature(h, 215.) # M109 S215 T0, value-equal + reactor.now += 1 + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 215. + assert h.target_temp == 215. + assert any('slicer request' in s for s in bg.said) + + +def test_field_case_m109_220_after_claim(): + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + reactor.now += 60 + ph.set_temperature(h, 220.) # Orca M104 S220 T0 + reactor.now += 24 + ph.set_temperature(h, 220.) # M109 S220 T0 before the T + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 220. + assert h.target_temp == 220. + + +def test_no_foreign_request_restores_found_target(): + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 70.) + reactor.now += 1 + ph.set_temperature(h, 70.) # a request BEFORE the claim + reactor.now += 1 + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 250.) + reactor.now += 40 + assert bg._release_heat(ace, 0, h, ph, 'bg load done') == 70. + assert any('target found at takeover' in s for s in bg.said) + + +def test_found_zero_and_nothing_asked_cools_to_zero(): + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 0.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 0. + assert h.target_temp == 0. + assert not bg.said + + +def test_print_ended_during_op_cools_to_zero(): + """TURN_OFF_HEATERS at print end/cancel bypasses the observer; once + print_stats is neither printing nor paused the intent is 0.""" + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + reactor.now += 5 + ph.set_temperature(h, 220.) + ps.state = 'cancelled' + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 0. + assert h.target_temp == 0. + assert any('print ended' in s for s in bg.said) + + +def test_paused_print_keeps_intent(): + ace, bg, ph, reactor, ps = _rig(print_state='paused') + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 70. + + +def test_display_zero_during_op_wins(): + """control/extruder_temp from the touchscreen runs through + pheaters.set_temperature too: a foreign 0 is the newest intent.""" + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + reactor.now += 5 + ph.set_temperature(h, 0.) + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 0. + assert h.target_temp == 0. + + +def test_second_release_is_a_noop(): + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + reactor.now += 5 + ph.set_temperature(h, 220.) + assert bg._release_heat(ace, 0, h, ph, 'feed failure') == 220. + n = len(ph.calls) + assert bg._release_heat(ace, 0, h, ph, 'load abort') is None + assert len(ph.calls) == n + assert h.target_temp == 220. + + +def test_old_ace_without_observer_restores_found_target(): + ace, bg, ph, reactor, ps = _rig(with_observer=False) + h = FakeHeater('extruder', 70.) + bg._heat_claim(0, h) + bg._set_heat(ace, ph, h, 215.) + assert h.target_temp == 215. + assert bg._release_heat(ace, 0, h, ph, 'cold-pull') == 70. + assert h.target_temp == 70. + + +def test_heat_set_own_never_waits(): + """_own_heat_depth is process-wide, not per greenlet: a waiting own set + would yield the reactor and book a foreign M109 as ours. The helper + takes no wait argument and always passes wait=False.""" + import inspect + ace, bg, ph, reactor, ps = _rig() + h = FakeHeater('extruder') + sig = inspect.signature(ace.heat_set_own) + assert 'wait' not in sig.parameters + ace.heat_set_own(ph, h, 215.) + assert ph.calls == [('extruder', 215., False)] + try: + ace.heat_set_own(ph, h, 215., True) + except TypeError: + pass + else: + raise AssertionError('heat_set_own must not accept a wait flag')