mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
Back-to-back prints in chamber-heated materials (ASA, ABS, PA, PC) each paid
a full heat-soak from cold, even when the print that just finished had left
the chamber at temperature. Two changes remove that cost.
Keep bed warm between prints
While a printer sits in FINISH awaiting plate-clear and the next queued item
needs chamber heat, hold the bed hot so the chamber does not cool during the
bed-clearing window. The bed is the chamber's heating element here, not a
print surface, so the hold runs at the new `queue_keep_warm_bed_temp`
(default 90C, which also satisfies bed-threshold-linked aftermarket chamber
heaters), raised to the item's own bed temperature when that is higher.
Gated on `queue_keep_bed_warm` AND `require_plate_clear` AND
`preheat_enabled`, all re-checked in the backend so a stale UI cannot leave
the feature running. `queue_keep_warm_max_minutes` (default 120) bounds the
hold: when it elapses the bed is switched off and the hold latches until the
printer is next a candidate, so a plate nobody clears cannot leave the bed
hot indefinitely. The hold is also released when the item is deleted, the
queue empties, or a gate is toggled off mid-hold, and never when firmware
reports a target other than the one it set — a temperature the user or a
print changed is left alone. Publishing is idempotent.
Smart soak reduction from chamber history
The scheduler samples each connected printer's chamber temperature every tick
into a 2h rolling history. Preheat credits time the chamber has already spent
at temperature against the configured soak, shortening or skipping it.
Credit starts no earlier than the newest sample, the most recent unbroken run
of samples, or the end of the last real dip below target. A dip only counts
once it outlasts a grace period: an enclosed chamber cannot lose and regain
several degrees quickly (measured on an X1C, cooling from 55C to below 48C
takes 23-73 minutes, ~0.2 C/min), so a brief low reading is a door opening or
sensor noise rather than lost soak — and a plate swap, which is exactly when
keep-warm runs, produces one. A stale history credits nothing: at that
cooling rate the chamber can cross the threshold unobserved, so the full soak
runs instead.
Three supporting changes to preheat itself:
* Cancelling or deleting a queued item now stops a preheat already running
for it. Those routes only write `status` to the database, which a dispatch
coroutine parked in `asyncio.sleep` cannot observe, so the heaters ran for
the rest of max_wait + soak — 45 minutes at the default settings — and the
printer stayed in `busy_printers`, blocking every other queued item behind
a print that was not happening. The routes now signal the scheduler
directly, and the stage sleeps in slices so it notices promptly and
abandons the dispatch, letting the existing rollback shut the heaters off.
* A chamber-heated print whose slicer metadata carries no bed temperature
(common for Orca-exported 3MFs) used to skip preheat entirely and start
with a cold chamber. It now heats the bed to `queue_keep_warm_bed_temp`.
A parsed bed temperature still wins, and a print with no chamber
requirement still skips — no bed temperature is invented for the print
itself. Preheat's bed target is transient regardless: the print's own
gcode issues its M140/M190 at start.
* Preheat records which commands it sent (bed, chamber, airduct) and unwinds
them if the dispatch aborts before the print starts — a failed upload, a
cancelled item, an exception — instead of leaving the printer heating for
a job that is not happening.
694 lines
29 KiB
Python
694 lines
29 KiB
Python
"""Tests for the `_preheat_and_soak` fast-path short-circuit.
|
|
|
|
When the chamber has already been at temperature for the full soak duration
|
|
AND the bed is currently at target, the preheat stage skips the convergence
|
|
wait and soak entirely. Before the fix it returned WITHOUT sending M140,
|
|
airduct, or M141 — the bed cooled while the 3MF uploaded. The regression
|
|
guard here is: fast path fires ⇒ all applicable heater/flap commands are
|
|
sent, and the slow path (convergence wait + soak) is skipped.
|
|
|
|
Distinguishing the paths is done via `db.commit` — the slow path commits
|
|
before the convergence loop (releases the pooled connection during the
|
|
sleep-heavy wait) so `db.commit.await_count == 0` is a reliable signal
|
|
that the fast path returned early.
|
|
"""
|
|
|
|
from collections import deque
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.print_scheduler import (
|
|
_AIRDUCT_MODE_COOLING,
|
|
_AIRDUCT_MODE_HEATING,
|
|
PrintScheduler,
|
|
)
|
|
|
|
NOW = 10_000.0
|
|
PRINTER_ID = 7
|
|
|
|
|
|
@pytest.fixture
|
|
def scheduler():
|
|
return PrintScheduler()
|
|
|
|
|
|
@pytest.fixture
|
|
def item():
|
|
return SimpleNamespace(
|
|
id=42,
|
|
preheat_override="inherit",
|
|
preheat_chamber_target_override=60, # forces chamber_target=60, do_chamber=True
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def archive():
|
|
return SimpleNamespace(bed_temperature=60)
|
|
|
|
|
|
def _make_printer(model: str, printer_id: int = PRINTER_ID):
|
|
return SimpleNamespace(id=printer_id, model=model)
|
|
|
|
|
|
def _make_client():
|
|
client = MagicMock()
|
|
client.set_bed_temperature = MagicMock(return_value=True)
|
|
client.set_chamber_temperature = MagicMock(return_value=True)
|
|
client.set_airduct_mode = MagicMock(return_value=True)
|
|
return client
|
|
|
|
|
|
def _make_state(*, bed_temp=0.0, chamber_temp=0.0, airduct_mode=_AIRDUCT_MODE_COOLING):
|
|
return SimpleNamespace(
|
|
temperatures={"bed": bed_temp, "chamber": chamber_temp},
|
|
raw_data={},
|
|
airduct_mode=airduct_mode,
|
|
)
|
|
|
|
|
|
def _ints(**values):
|
|
return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
|
|
|
|
|
|
def _preload_dense_history(scheduler, *, printer_id=PRINTER_ID, chamber_temp=62.0, duration=1800, interval=30):
|
|
"""Pre-fill scheduler._chamber_history so _chamber_soak_remaining returns 0.
|
|
|
|
Uses dense samples (30s apart) covering the full soak window so the
|
|
contiguity guard sees an unbroken run.
|
|
"""
|
|
d: deque = deque()
|
|
ts = NOW - duration
|
|
while ts <= NOW:
|
|
d.append((ts, float(chamber_temp)))
|
|
ts += interval
|
|
scheduler._chamber_history[printer_id] = d
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fast path fires — sends all applicable targets
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_fires_sends_bed_airduct_chamber_on_h2d(scheduler, item, archive):
|
|
"""H2D (heater + airduct + sensor) hits the fast path with all three commands."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(60)
|
|
client.set_chamber_temperature.assert_called_once_with(60)
|
|
client.set_airduct_mode.assert_called_once_with("heating")
|
|
# Slow path commits `db` before the convergence wait; fast path returns first.
|
|
assert db.commit.await_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_fires_sends_bed_only_on_x1c(scheduler, item, archive):
|
|
"""X1C has a chamber sensor but no heater and no airduct — only M140 fires."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(60)
|
|
client.set_chamber_temperature.assert_not_called()
|
|
client.set_airduct_mode.assert_not_called()
|
|
assert db.commit.await_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skips_airduct_when_already_in_heating(scheduler, item, archive):
|
|
"""Airduct already reported as heating → do NOT publish set_airduct_mode."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0, airduct_mode=_AIRDUCT_MODE_HEATING)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(60)
|
|
client.set_chamber_temperature.assert_called_once_with(60)
|
|
client.set_airduct_mode.assert_not_called() # idempotence guard
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fast path DOES NOT fire — falls through to slow path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skipped_when_no_history(scheduler, item, archive):
|
|
"""Empty chamber history → _chamber_soak_remaining returns full soak → slow path."""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
# Slow path commits `db` before the convergence wait.
|
|
assert db.commit.await_count >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skipped_when_bed_too_cold(scheduler, item, archive):
|
|
"""Bed below target - 2 → cannot skip preheat, falls through to slow path."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
# Bed at 30°C, way below 60°C target — fast path condition fails.
|
|
pm.get_status.return_value = _make_state(bed_temp=30.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
assert db.commit.await_count >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skipped_when_chamber_currently_below_target(scheduler, item, archive):
|
|
"""Chamber history shows history but current chamber reading is cold → slow path."""
|
|
_preload_dense_history(scheduler) # history says "hot"
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
# Bed at target, but current chamber reading is 40°C (below 58 = 60-2).
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=40.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
assert db.commit.await_count >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skipped_when_no_sensor_model(scheduler, item, archive):
|
|
"""P1S has no chamber sensor → has_sensor=False → fast path condition fails."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
|
|
|
|
assert db.commit.await_count >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skipped_when_soak_seconds_zero(scheduler, item, archive):
|
|
"""soak_seconds=0 disables the fast path (nothing to skip) — slow path runs."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0, preheat_max_wait_seconds=1)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
assert db.commit.await_count >= 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Preheat rollback pin: fast path populates it correctly for `_dispatch_one`
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_registers_all_actions_in_pin_on_h2d(scheduler, item, archive):
|
|
"""H2D fast path fires bed + airduct + chamber → pin has all three keys."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed", "airduct", "chamber"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_registers_only_bed_in_pin_on_x1c(scheduler, item, archive):
|
|
"""X1C fast path fires bed only (no heater, no airduct) → pin has just 'bed'."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
|
|
|
|
assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fast_path_skips_airduct_pin_when_already_heating(scheduler, item, archive):
|
|
"""Airduct already in heating → not published, not added to pin (nothing to unwind)."""
|
|
_preload_dense_history(scheduler)
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=900)),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0, airduct_mode=_AIRDUCT_MODE_HEATING)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
|
|
|
|
assert scheduler._preheat_pin.get(PRINTER_ID) == {"bed", "chamber"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _rollback_preheat_pin: unwinds every registered action, best-effort, no raise
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_rollback_preheat_pin_unwinds_all_three_actions(scheduler):
|
|
"""Pin contains all three keys → three cleanup commands fire, pin dict shrinks."""
|
|
scheduler._preheat_pin[PRINTER_ID] = {"bed", "chamber", "airduct"}
|
|
client = _make_client()
|
|
|
|
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
pm.get_client.return_value = client
|
|
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(0)
|
|
client.set_chamber_temperature.assert_called_once_with(0)
|
|
client.set_airduct_mode.assert_called_once_with("cooling")
|
|
assert PRINTER_ID not in scheduler._preheat_pin
|
|
|
|
|
|
def test_rollback_preheat_pin_only_unwinds_registered_keys(scheduler):
|
|
"""Pin has only {bed} → only that command fires; chamber/airduct untouched."""
|
|
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
|
|
client = _make_client()
|
|
|
|
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
pm.get_client.return_value = client
|
|
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(0)
|
|
client.set_chamber_temperature.assert_not_called()
|
|
client.set_airduct_mode.assert_not_called()
|
|
|
|
|
|
def test_rollback_preheat_pin_noop_when_pin_absent(scheduler):
|
|
"""No pin entry for this printer → no client lookup, no commands, no crash."""
|
|
client = _make_client()
|
|
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
pm.get_client.return_value = client
|
|
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
|
|
|
|
client.set_bed_temperature.assert_not_called()
|
|
|
|
|
|
def test_rollback_preheat_pin_noop_when_client_missing(scheduler):
|
|
"""Client is None (e.g. printer deregistered mid-dispatch) → no crash, pin still popped."""
|
|
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
|
|
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
pm.get_client.return_value = None
|
|
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
|
|
|
|
# Pin was consumed even though there was nothing to send to.
|
|
assert PRINTER_ID not in scheduler._preheat_pin
|
|
|
|
|
|
def test_rollback_preheat_pin_swallows_setter_exceptions(scheduler):
|
|
"""A setter raising must not propagate — the interesting exception is upstream."""
|
|
scheduler._preheat_pin[PRINTER_ID] = {"bed", "chamber", "airduct"}
|
|
client = _make_client()
|
|
client.set_bed_temperature.side_effect = RuntimeError("mqtt down")
|
|
client.set_chamber_temperature.side_effect = RuntimeError("mqtt down")
|
|
client.set_airduct_mode.side_effect = RuntimeError("mqtt down")
|
|
|
|
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
|
|
pm.get_client.return_value = client
|
|
# Must not raise.
|
|
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
|
|
|
|
assert PRINTER_ID not in scheduler._preheat_pin
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Missing bed_temperature metadata: heat the bed anyway when the chamber needs it
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_falls_back_to_configured_bed_temp_when_metadata_missing(scheduler, item):
|
|
"""No parsed bed temperature + chamber target > 0 → heat the bed to the configured temp.
|
|
|
|
Previously preheat returned immediately ("archive has no bed_temperature
|
|
metadata"), so the chamber phase never ran and the print started cold —
|
|
the exact outcome preheat exists to prevent. The bed is how the chamber
|
|
gets hot, so a missing bed temperature must not disable the stage.
|
|
"""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
archive_no_bed = SimpleNamespace(bed_temperature=None)
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
scheduler,
|
|
"_get_int_setting",
|
|
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
|
|
),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive_no_bed)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(90)
|
|
# The stage ran rather than returning early (slow path commits before waiting).
|
|
assert db.commit.await_count >= 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_fallback_honours_configured_temp(scheduler, item):
|
|
"""The fallback reads `queue_keep_warm_bed_temp`; it is not hard-coded."""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
archive_no_bed = SimpleNamespace(bed_temperature=None)
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
scheduler,
|
|
"_get_int_setting",
|
|
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=100),
|
|
),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive_no_bed)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(100)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_still_skips_when_no_bed_temp_and_no_chamber_target(scheduler):
|
|
"""No bed metadata AND no chamber requirement → nothing to preheat for; skip.
|
|
|
|
Guards the unchanged half of the branch: a PLA print with no parsed bed
|
|
temperature must not have one invented for it.
|
|
"""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
pla_item = SimpleNamespace(id=43, preheat_override="inherit", preheat_chamber_target_override=0)
|
|
archive_no_bed = SimpleNamespace(bed_temperature=None)
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
scheduler,
|
|
"_get_int_setting",
|
|
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
|
|
),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
|
|
await scheduler._preheat_and_soak(db, pla_item, _make_printer("X1C"), archive_no_bed)
|
|
|
|
client.set_bed_temperature.assert_not_called()
|
|
assert db.commit.await_count == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_prefers_parsed_bed_temp_over_fallback(scheduler, item, archive):
|
|
"""A parsed bed temperature is used as-is — the fallback only fills a gap."""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
scheduler,
|
|
"_get_int_setting",
|
|
_ints(preheat_soak_seconds=0, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
|
|
),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=20.0, chamber_temp=20.0)
|
|
# archive fixture carries bed_temperature=60
|
|
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
|
|
|
|
client.set_bed_temperature.assert_called_once_with(60)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cancellation during preheat: stop heating, abandon the dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_aborts_when_item_cancelled_during_soak(scheduler, item, archive):
|
|
"""Cancelling mid-soak stops the wait instead of holding the full duration.
|
|
|
|
Cancelling only writes `status` to the database — it cannot interrupt a
|
|
coroutine parked in `asyncio.sleep`. Before this, the stage slept out the
|
|
remaining soak (up to 30 min) with the heaters on, and kept the printer in
|
|
`busy_printers` the whole time, blocking every other queued item.
|
|
"""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
scheduler._inflight[item.id] = (MagicMock(), PRINTER_ID)
|
|
scheduler.notify_dispatch_cancelled(item.id)
|
|
slept: list[float] = []
|
|
|
|
async def _fake_sleep(secs):
|
|
slept.append(secs)
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
scheduler,
|
|
"_get_int_setting",
|
|
_ints(preheat_soak_seconds=1800, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
|
|
),
|
|
# The queue route has flagged this dispatch as cancelled.
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
proceed = await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
|
|
|
|
assert proceed is False
|
|
# Bailed after the first slice rather than sleeping the whole soak.
|
|
assert sum(slept) <= 10.0, f"slept {sum(slept)}s — should abort on the first check"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_completes_when_item_stays_live(scheduler, item, archive):
|
|
"""The happy path still returns True so the dispatch proceeds to upload."""
|
|
db = AsyncMock()
|
|
client = _make_client()
|
|
|
|
with (
|
|
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
|
|
patch.object(
|
|
scheduler,
|
|
"_get_int_setting",
|
|
_ints(preheat_soak_seconds=20, preheat_max_wait_seconds=0, queue_keep_warm_bed_temp=90),
|
|
),
|
|
patch("backend.app.services.print_scheduler.time") as t,
|
|
patch("backend.app.services.print_scheduler.printer_manager") as pm,
|
|
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
|
|
):
|
|
t.monotonic.return_value = NOW
|
|
pm.get_client.return_value = client
|
|
pm.get_status.return_value = _make_state(bed_temp=60.0, chamber_temp=62.0)
|
|
proceed = await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
|
|
|
|
assert proceed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_skip_paths_still_return_true(scheduler, archive):
|
|
"""`preheat_override='off'` skips the stage but must NOT abandon the dispatch."""
|
|
db = AsyncMock()
|
|
off_item = SimpleNamespace(id=44, preheat_override="off", preheat_chamber_target_override=60)
|
|
|
|
with patch("backend.app.services.print_scheduler.printer_manager"):
|
|
proceed = await scheduler._preheat_and_soak(db, off_item, _make_printer("X1C"), archive)
|
|
|
|
assert proceed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_sleep_slices_and_stops_on_cancel(scheduler):
|
|
"""`_preheat_sleep` chops a long wait up and bails at the first check after the flag lands.
|
|
|
|
The slicing is the whole point: a single `asyncio.sleep(1800)` cannot
|
|
observe a cancellation that arrives while it is parked.
|
|
"""
|
|
slept: list[float] = []
|
|
|
|
# The dispatch is in flight, which is the only state a cancellation is
|
|
# recorded for.
|
|
scheduler._inflight[1] = (MagicMock(), PRINTER_ID)
|
|
|
|
async def _fake_sleep(secs):
|
|
slept.append(secs)
|
|
# Cancellation lands part-way through, as it would from the API.
|
|
if len(slept) == 3:
|
|
scheduler.notify_dispatch_cancelled(1)
|
|
|
|
with patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep):
|
|
ok = await scheduler._preheat_sleep(item_id=1, seconds=1800)
|
|
|
|
assert ok is False
|
|
assert len(slept) == 3, "should stop at the check following the cancellation"
|
|
assert max(slept) <= 10.0, "each slice is bounded by the cancel-check interval"
|
|
|
|
|
|
def test_notify_dispatch_cancelled_is_scoped_to_the_item(scheduler):
|
|
"""The flag names one item; an unrelated dispatch must not see it."""
|
|
scheduler._inflight[42] = (MagicMock(), PRINTER_ID)
|
|
scheduler.notify_dispatch_cancelled(42)
|
|
assert 42 in scheduler._cancelled_dispatches
|
|
assert 43 not in scheduler._cancelled_dispatches
|
|
|
|
|
|
def test_notify_dispatch_cancelled_ignores_items_not_in_flight(scheduler):
|
|
"""Cancelling a merely-pending item records nothing.
|
|
|
|
Every cancel and delete calls this, but only a dispatch that is already
|
|
running can be interrupted by it. Recording the rest would grow the set
|
|
once per cancelled item for the life of the process, and buys nothing:
|
|
`_claim_for_dispatch` only claims rows that are still `pending`, and the
|
|
caller has committed a terminal status (or deleted the row) first.
|
|
"""
|
|
scheduler.notify_dispatch_cancelled(99)
|
|
assert scheduler._cancelled_dispatches == set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_preheat_sleep_runs_to_completion_when_not_cancelled(scheduler):
|
|
"""No flag set → the full duration is slept and True is returned."""
|
|
slept: list[float] = []
|
|
|
|
async def _fake_sleep(secs):
|
|
slept.append(secs)
|
|
|
|
with patch("backend.app.services.print_scheduler.asyncio.sleep", _fake_sleep):
|
|
ok = await scheduler._preheat_sleep(item_id=7, seconds=25)
|
|
|
|
assert ok is True
|
|
assert sum(slept) == pytest.approx(25.0)
|