Files
bambuddy/backend/tests/unit/test_scheduler_preheat.py
T
Thomas Scott Williams f86f5a7c34 feat(queue): keep the chamber warm between prints and skip redundant soak
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.
2026-08-09 17:07:34 -04:00

621 lines
28 KiB
Python

"""Tests for the preheat & heat-soak scheduler stage (#1468).
Three layered concerns the stage has to get right:
1. **Override resolution** (per-item beats global beats default).
2. **Chamber-target derivation** (item-override > filament-map max > 0).
3. **Hardware-tier branching** (chamber heater vs sensor-only vs no sensor).
The fixtures construct a queue item with `preheat_override` + the override
target both unset; tests flip those per case. `asyncio.sleep` is patched to
AsyncMock so the soak phase doesn't actually wait — assertions are on what
got scheduled, not wall-clock.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import PrintScheduler
@pytest.fixture
def scheduler():
return PrintScheduler()
@pytest.fixture
def item():
return SimpleNamespace(
id=42,
preheat_override="inherit",
preheat_chamber_target_override=None,
)
@pytest.fixture
def archive():
return SimpleNamespace(bed_temperature=60)
def _make_printer(model: str, printer_id: int = 7):
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: float = 0.0,
chamber_temp: float = 0.0,
trays: list[str] | None = None,
airduct_mode: int = 0,
):
"""Build a PrinterState-shaped namespace with optional AMS tray types.
`trays` is a list of tray_type strings (each becomes one loaded slot in
AMS unit 0). Empty / None gives an empty AMS — the derivation falls
through to 0. `airduct_mode` is 0 (cooling, default) or 1 (heating);
matches the field on PrinterState that the preheat stage reads to
decide whether to fire a redundant `set_airduct_mode` call."""
raw_data: dict = {}
if trays is not None:
raw_data["ams"] = [{"tray": [{"tray_type": t} for t in trays]}]
return SimpleNamespace(
temperatures={"bed": bed_temp, "chamber": chamber_temp},
raw_data=raw_data,
airduct_mode=airduct_mode,
)
def _ints(**values):
"""Mock side_effect for _get_int_setting that returns the kwarg value
when the key matches, else the helper's `default` argument."""
return AsyncMock(side_effect=lambda _db, key, default: values.get(key, default))
# ----------------------------------------------------------------------------
# Override resolution
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_global_disabled_inherit_skips(scheduler, item, archive):
"""preheat_enabled=False + item.preheat_override='inherit' → no heater dispatch."""
db = AsyncMock()
client = _make_client()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
patch.object(scheduler, "_get_int_setting", _ints()),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
pm.get_client.return_value = client
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_bed_temperature.assert_not_called()
client.set_chamber_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_item_override_off_bypasses_global_on(scheduler, item, archive):
"""preheat_enabled=True + item.preheat_override='off' → preheat suppressed."""
db = AsyncMock()
client = _make_client()
item.preheat_override = "off"
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints()),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
pm.get_client.return_value = client
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_bed_temperature.assert_not_called()
client.set_chamber_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_item_override_on_runs_despite_global_off(scheduler, item, archive):
"""preheat_enabled=False + item.preheat_override='on' → preheat runs (bed
fires, chamber depends on the resolved target)."""
db = AsyncMock()
client = _make_client()
item.preheat_override = "on"
item.preheat_chamber_target_override = 0 # explicit no-chamber so the assertion is sharp
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=False)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 0.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_not_called()
# ----------------------------------------------------------------------------
# Chamber-target derivation
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_chamber_target_override_beats_filament_map(scheduler, item, archive):
"""item.preheat_chamber_target_override is the highest-priority source —
a PLA-only print with an explicit 50°C override still heats the chamber."""
db = AsyncMock()
client = _make_client()
item.preheat_chamber_target_override = 50
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# Only PLA loaded — filament map would derive 0; override forces 50.
pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic"])
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(50)
@pytest.mark.asyncio
async def test_filament_map_picks_max_across_loaded_slots(scheduler, item, archive):
"""Mixed PA + PLA load: PA=50 + PLA=0 → chamber target 50 (the max).
The "lowest common denominator" model is wrong here; PA's requirement
is the binding constraint."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# `PA Basic` (note the space) normalises to `PA` which the bundled
# map keys against; a hyphenated `PA-Generic` would normalise to
# `PA-GENERIC` and fall through to `default` (0) — that's a separate
# behaviour the user editor handles by adding a custom key.
pm.get_status.return_value = _make_state(60.0, 52.0, trays=["PLA Basic", "PA Basic"])
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_chamber_temperature.assert_called_once_with(50) # PA's recommendation, not PLA's
@pytest.mark.asyncio
async def test_pla_only_derives_zero_chamber_skips(scheduler, item, archive):
"""PLA-only print: filament-map lookup returns 0 → chamber phase skips
automatically without the user touching anything."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 0.0, trays=["PLA", "PLA"])
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_not_called()
@pytest.mark.asyncio
async def test_unknown_filament_type_falls_to_default(scheduler, item, archive):
"""A loaded tray with a type not in the map uses the `default` entry —
keeps users with custom filament names safe (they get 0 by default,
can be tuned via the per-filament editor)."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 0.0, trays=["MyCustomFilament"])
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_not_called() # default = 0
@pytest.mark.asyncio
async def test_custom_filament_targets_json_parses(scheduler, item, archive):
"""User-customised filament-target JSON overrides the bundled defaults —
raising PLA to 30°C makes a PLA-only print actually heat the chamber."""
db = AsyncMock()
client = _make_client()
custom_map = '{"PLA": 30, "default": 0}'
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=custom_map)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 31.0, trays=["PLA Basic"])
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_chamber_temperature.assert_called_once_with(30)
@pytest.mark.asyncio
async def test_malformed_filament_targets_falls_back_to_defaults(scheduler, item, archive):
"""A corrupted JSON in the setting must not break the scheduler — log
and use bundled defaults."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value="not-json{{{")),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# Bundled default for ABS = 45 → chamber should fire.
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_chamber_temperature.assert_called_once_with(45)
# ----------------------------------------------------------------------------
# Hardware-tier branching (unchanged from the first cut but updated for new
# fixtures that include AMS data so the derivation lands at a non-zero target).
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_no_bed_temperature_but_chamber_needed_heats_bed_to_configured_temp(scheduler, item):
"""Archive without bed_temperature still preheats when the chamber needs heat.
This branch used to return early, on the reasoning that guessing a bed
temperature could wreck a print. Two things make the fallback safe, and
skipping actively harmful:
* Preheat's bed target is transient. The print's own gcode issues its
M140/M190 the moment it starts, so preheat can never set the temperature
the print actually runs at — it only decides how warm things are while
the file uploads.
* The fallback is gated on a non-zero chamber target, so it only applies to
materials the filament map says want a hot chamber (ABS/ASA/PC …). The
original concern — inventing a bed temperature for a PLA print — is still
guarded, and covered by the sibling test below.
Skipping meant a chamber-heated print whose slicer metadata carries no bed
temperature started with a cold chamber, which is what preheat exists to
prevent.
"""
db = AsyncMock()
client = _make_client()
bare_archive = SimpleNamespace(bed_temperature=None)
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(
scheduler,
"_get_int_setting",
_ints(queue_keep_warm_bed_temp=90, preheat_soak_seconds=0, preheat_max_wait_seconds=0),
),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# Bed/chamber already at temperature so the convergence loop exits on its
# first pass — its deadline is wall-clock, so a mocked `asyncio.sleep`
# would otherwise spin for the full max_wait in real time.
pm.get_status.return_value = _make_state(90.0, 46.0, trays=["ABS"])
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
client.set_bed_temperature.assert_called_once_with(90)
@pytest.mark.asyncio
async def test_no_bed_temperature_and_no_chamber_target_still_skips(scheduler, item):
"""PLA (chamber target 0) with no bed metadata → skip, as before.
Preserves the original guard: with nothing to preheat *for*, no bed
temperature is invented for the print.
"""
db = AsyncMock()
client = _make_client()
bare_archive = SimpleNamespace(bed_temperature=None)
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(queue_keep_warm_bed_temp=90)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(trays=["PLA"])
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), bare_archive)
client.set_bed_temperature.assert_not_called()
client.set_chamber_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_x1c_skips_m141_but_waits_passively(scheduler, item, archive):
"""X1C has a chamber sensor but no active heater — M141 must NOT fire even
when the filament map derives a non-zero target."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# ABS loaded → derived target 45; sensor reads 46 (already there).
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
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()
@pytest.mark.asyncio
async def test_p1s_no_chamber_sensor_uses_soak_timer_only(scheduler, item, archive):
"""P1S has no chamber sensor — derived target is ignored for the wait
loop, only the soak timer applies."""
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=600)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()) as sleep_mock,
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 0.0, trays=["ABS"])
await scheduler._preheat_and_soak(db, item, _make_printer("P1S"), archive)
client.set_bed_temperature.assert_called_once_with(60)
client.set_chamber_temperature.assert_not_called()
# The soak is slept in slices so a cancellation landing mid-hold is noticed
# (a single 600s sleep could not see one), so assert the total rather than a
# single call of the full duration.
assert sum(call.args[0] for call in sleep_mock.call_args_list) == 600
@pytest.mark.asyncio
async def test_lost_client_skips_silently(scheduler, item, archive):
"""If the MQTT client drops, the helper returns without raising."""
db = AsyncMock()
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints()),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
pm.get_client.return_value = None
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
# No exception escaping — the disable path is silent.
@pytest.mark.asyncio
async def test_h2d_flips_airduct_to_heating_before_m141(scheduler, item, archive):
"""H-series + X2D have a cooling/heating airduct flap that DEFAULTS to
cooling. If we energise M141 without first flipping to heating, the
chamber fan actively extracts the heat we're trying to put in and the
chamber never converges. Verify airduct=heating fires AND lands before
the chamber-target call so the heater starts in the right airflow regime."""
db = AsyncMock()
client = _make_client()
call_order = []
client.set_airduct_mode.side_effect = lambda mode: call_order.append(("airduct", mode)) or True
client.set_chamber_temperature.side_effect = lambda t: call_order.append(("chamber", t)) or True
with (
patch.object(scheduler, "_get_bool_setting", AsyncMock(return_value=True)),
patch.object(scheduler, "_get_int_setting", _ints(preheat_soak_seconds=0)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_airduct_mode.assert_called_once_with("heating")
client.set_chamber_temperature.assert_called_once_with(45)
# Airduct heating must precede M141 — the heater enabling first while the
# flap is still in cooling mode wastes minutes of fan-vs-heater tug-of-war.
assert call_order == [("airduct", "heating"), ("chamber", 45)]
@pytest.mark.asyncio
async def test_x1c_skips_airduct_no_heater_no_call(scheduler, item, archive):
"""X1C has neither an active chamber heater nor an airduct flap (the
frontend's airduct whitelist is P2S/X2D/H2D/H2C/H2S/H2D Pro — no X1
series). The preheat stage's airduct call is gated on supports_airduct
AND has_heater, so X1C gets neither call regardless. Important: a
spurious set_airduct on X1C wouldn't just be wasted MQTT — there's no
flap to set, so the firmware response would be undefined behaviour."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"])
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
client.set_chamber_temperature.assert_not_called()
client.set_airduct_mode.assert_not_called()
@pytest.mark.asyncio
async def test_normalize_filament_type_strips_at_space():
"""`PLA Basic` and `ABS Premium` should normalise to `PLA` and `ABS` so
they match the map keys. `PA-CF` has no space and stays verbatim."""
s = PrintScheduler
assert s._normalize_filament_type("PLA Basic") == "PLA"
assert s._normalize_filament_type("ABS Premium") == "ABS"
assert s._normalize_filament_type("PA-CF") == "PA-CF"
assert s._normalize_filament_type("") == ""
assert s._normalize_filament_type("petg") == "PETG" # case-folded
@pytest.mark.asyncio
async def test_get_preheat_filament_targets_defaults_when_missing(scheduler):
"""Empty / null setting → bundled defaults are used. _get_preheat_filament_targets
upper-cases the keys, so the bundled `default` becomes `DEFAULT` on the
returned dict — keep both spellings synced."""
db = AsyncMock()
with patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)):
targets = await scheduler._get_preheat_filament_targets(db)
# The bundled defaults dict is kept as-is on the "no setting" path, so
# `default` (lowercase) is what callers see for that fallback.
assert targets["PLA"] == 0
assert targets["ABS"] == 45
assert targets["PA-CF"] == 55
# Either casing must resolve to the fallback 0.
assert targets.get("default", targets.get("DEFAULT")) == 0
# ----------------------------------------------------------------------------
# Airduct mode switch (#1468 follow-up)
# ----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_h2d_chamber_heat_switches_airduct_to_heating(scheduler, item, archive):
"""H2D in cooling mode (the default; what you get after a PLA print)
with chamber_target > 0 must switch the airduct to heating BEFORE the
M141 dispatch — otherwise the open exhaust flap actively fights the
chamber heater and the chamber never converges."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# Currently in cooling (mode 0). ABS loaded → derived target 45.
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_airduct_mode.assert_called_once_with("heating")
client.set_chamber_temperature.assert_called_once_with(45)
@pytest.mark.asyncio
async def test_h2d_chamber_zero_switches_airduct_to_cooling(scheduler, item, archive):
"""H2D running a PLA print (chamber_target derives 0) on an airduct
previously left in heating mode (from a prior ABS run) must switch
back to cooling. Otherwise PLA prints inherit ABS's closed-flap recirc
and run 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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# Currently in heating (mode 1). PLA loaded → derived target 0.
pm.get_status.return_value = _make_state(60.0, 30.0, trays=["PLA"], airduct_mode=1)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_airduct_mode.assert_called_once_with("cooling")
client.set_chamber_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_h2d_airduct_already_correct_idempotent(scheduler, item, archive):
"""If the airduct is already in the desired mode, don't re-send
`set_airduct` — the firmware accepts it but it generates needless MQTT
chatter and could thrash the flap motor on rapid repeats."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
# Already in heating (mode 1) and ABS → derived 45 wants heating.
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=1)
await scheduler._preheat_and_soak(db, item, _make_printer("H2D"), archive)
client.set_airduct_mode.assert_not_called()
# But M141 still fires — the airduct decision is independent.
client.set_chamber_temperature.assert_called_once_with(45)
@pytest.mark.asyncio
async def test_x1c_no_airduct_flap_never_fires_set_airduct(scheduler, item, archive):
"""X1C has a chamber sensor but no airduct flap — the firmware ignores
`set_airduct`. We gate on `supports_airduct(model)` to avoid sending the
no-op. Regression guard: wiring this to `supports_chamber_temp` or
`supports_chamber_heater` alone would have leaked the command to
X1C/X1E or P2S inappropriately."""
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)),
patch.object(scheduler, "_get_setting", AsyncMock(return_value=None)),
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch("backend.app.services.print_scheduler.asyncio.sleep", AsyncMock()),
):
pm.get_client.return_value = client
pm.get_status.return_value = _make_state(60.0, 46.0, trays=["ABS"], airduct_mode=0)
await scheduler._preheat_and_soak(db, item, _make_printer("X1C"), archive)
client.set_airduct_mode.assert_not_called()