Merge pull request #2727 from ticfinack/feature/queue-keep-warm-chamber-history

feat(queue): keep bed warm + smart chamber soak reduction for consecutive prints requiring chamber heat
This commit is contained in:
MartinNYHC 2026-08-10 13:55:24 +02:00 committed by GitHub
commit f982da2a49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3177 additions and 37 deletions

View file

@ -1661,6 +1661,7 @@ async def cancel_batch(
)
pending_items = result.scalars().all()
cancelled_count = 0
cancelled_ids: list[int] = []
for item in pending_items:
item.status = "cancelled"
await release_budget_reservation(
@ -1669,11 +1670,19 @@ async def cancel_batch(
source_id=item.id,
status="released",
)
cancelled_ids.append(item.id)
cancelled_count += 1
batch.status = "cancelled"
await db.commit()
# Same as the single-item path: a dispatch already preheating for one of
# these cannot see the status change on its own (#2727).
from backend.app.services.print_scheduler import scheduler as _scheduler
for _cancelled_id in cancelled_ids:
_scheduler.notify_dispatch_cancelled(_cancelled_id)
return {"message": f"Batch cancelled, {cancelled_count} pending items cancelled"}
@ -1987,6 +1996,12 @@ async def delete_queue_item(
await db.delete(item)
await db.commit()
# Stop an in-flight preheat for this item: the dispatch coroutine is
# parked in a sleep and cannot see the status we just wrote (#2727).
from backend.app.services.print_scheduler import scheduler as _scheduler
_scheduler.notify_dispatch_cancelled(item_id)
logger.info("Deleted queue item %s", item_id)
return {"message": "Queue item deleted"}
@ -2104,6 +2119,12 @@ async def cancel_queue_item(
)
await db.commit()
# Stop an in-flight preheat for this item: the dispatch coroutine is
# parked in a sleep and cannot see the status we just wrote (#2727).
from backend.app.services.print_scheduler import scheduler as _scheduler
_scheduler.notify_dispatch_cancelled(item_id)
logger.info("Cancelled queue item %s", item_id)
return {"message": "Queue item cancelled"}

View file

@ -200,6 +200,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
"ldap_auto_provision",
"local_login_enabled",
"preheat_enabled",
"queue_keep_bed_warm",
]:
settings_dict[setting.key] = setting.value.lower() == "true"
elif setting.key in [
@ -228,6 +229,8 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
"pipeline_max_copies",
"preheat_max_wait_seconds",
"preheat_soak_seconds",
"queue_keep_warm_bed_temp",
"queue_keep_warm_max_minutes",
"queue_max_concurrent_uploads",
]:
settings_dict[setting.key] = int(setting.value)

View file

@ -470,6 +470,42 @@ class AppSettings(BaseModel):
le=1800,
description="Additional hold time at temperature after the chamber reaches the target (or after max_wait_seconds elapses). 0 = no soak.",
)
queue_keep_bed_warm: bool = Field(
default=False,
description=(
"While a printer is in FINISH state awaiting plate-clear and the next queued item requires "
"chamber heating, hold the bed hot so the chamber stays warm during the bed-clearing "
"window. The bed is the chamber's heating element here: the hold target is "
"queue_keep_warm_bed_temp, or the item's own bed_temperature when the slicer metadata "
"reports a higher one. Only fires for filaments with a non-zero chamber target "
"(ASA, ABS, PA, PC etc.); PLA/PETG prints are skipped automatically."
),
)
queue_keep_warm_bed_temp: int = Field(
default=90,
ge=40,
le=110,
description=(
"Bed temperature (°C) used when the bed's job is to heat the chamber. 90 sustains "
"chamber warmth on enclosed printers and satisfies bed-threshold-linked aftermarket "
"chamber heaters (which typically activate at bed ≥ 80). Applies in two places: the "
"keep-warm hold between chamber-heated prints, and preheat when a chamber-heated "
"item's slicer metadata carries no bed temperature at all. A parsed bed temperature "
"higher than this always wins, so the bed is never driven cooler than the print needs."
),
)
queue_keep_warm_max_minutes: int = Field(
default=120,
ge=5,
le=480,
description=(
"How long keep-warm may hold the bed on a printer waiting for its plate to be cleared. "
"When this elapses the bed is switched off, and the hold does not re-arm until the "
"printer next becomes a keep-warm candidate — so a plate nobody clears cannot leave the "
"bed hot indefinitely. Set it to how long you realistically take to reach the printer; "
"the only cost of it being too short is that the next print re-soaks from cold."
),
)
# User-configurable presets for the printer-card temperature / fan-speed
# popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
@ -680,6 +716,9 @@ class AppSettingsUpdate(BaseModel):
preheat_filament_targets: str | None = None
preheat_max_wait_seconds: int | None = Field(default=None, ge=60, le=3600)
preheat_soak_seconds: int | None = Field(default=None, ge=0, le=1800)
queue_keep_bed_warm: bool | None = None
queue_keep_warm_bed_temp: int | None = Field(default=None, ge=40, le=110)
queue_keep_warm_max_minutes: int | None = Field(default=None, ge=5, le=480)
nozzle_temp_presets: str | None = None
bed_temp_presets: str | None = None
chamber_temp_presets: str | None = None

View file

@ -5,6 +5,7 @@ import json
import logging
import time
import uuid
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@ -73,6 +74,61 @@ logger = logging.getLogger(__name__)
# sub-200 ms files.
_DISPATCH_PROGRESS_BYTE_STEP = 256 * 1024
_DISPATCH_PROGRESS_MIN_INTERVAL_SECS = 0.2
# How far back chamber temperature samples are retained. 2h comfortably spans
# any soak a user can configure (capped at 30 min) plus the plate-clearing gap
# before the next print.
_CHAMBER_HISTORY_TTL_SECONDS = 7200
# Fallback for `queue_keep_warm_max_minutes` — how long the bed may be held
# warm on a printer sitting in FINISH before the heaters are shut off. Users
# who clear plates promptly will want far less than this; it is deliberately
# the cautious end, since the cost of it being too short is only a re-soak.
_KEEP_WARM_MAX_MINUTES_DEFAULT = 120
# Max acceptable gap between two consecutive chamber samples before we treat
# the older one as belonging to a separate observation run (printer went
# offline and came back). Above ~30s cadence with a safety margin.
_CHAMBER_SAMPLE_MAX_GAP_SECONDS = 60.0
# How long the chamber must read below target before we accept that it really
# cooled. An enclosed chamber's thermal mass cannot lose and regain several
# degrees quickly: measured on an X1C, falling from ~55°C to below 48°C took
# 23-73 minutes (~0.2 C/min), while the fastest drop ever recorded was
# 27 C/min — impossible for that mass, i.e. a sensor artifact. Brief
# sub-target readings are therefore a door opening or noise, not lost soak,
# and a plate swap (exactly when keep-warm is running) produces one. Six
# minutes clears the longest such artifact observed (~5 min once bracketed by
# its neighbouring samples) and still sits far below the 23-minute floor for
# real cooling.
_CHAMBER_DIP_GRACE_SECONDS = 360.0
# How often the preheat stage re-checks that the item it is heating for still
# wants to be printed. Cancelling only writes `status` to the database — it
# cannot interrupt a coroutine parked in `asyncio.sleep` — so without this the
# heaters run for the rest of max_wait + soak (45 min at the default settings)
# and the printer stays in `busy_printers`, blocking every other queued item
# behind a print that is not happening.
_PREHEAT_CANCEL_CHECK_SECONDS = 10.0
# set_airduct_mode modeId values (bambu_mqtt.py:5937 — 0 cooling, 1 heating).
_AIRDUCT_MODE_COOLING = 0
_AIRDUCT_MODE_HEATING = 1
@dataclass
class _KeepWarmEntry:
"""Per-printer keep-warm state.
- ``started``: monotonic time when keep-warm first fired for this printer.
Used by the max-duration timeout.
- ``held_target``: last bed target we successfully published. On release
we only send bed-off when firmware still reports this value, so a user
or subsequent print that changed the target isn't clobbered.
- ``expired``: latched True when the max-duration timeout fires. Prevents
re-engagement (and re-seeding of ``started``) on subsequent ticks. The
release sweep drops the entry entirely once the printer leaves the
candidate set.
"""
started: float
held_target: int
expired: bool = False
# Auto-drying re-arm guards (#2770).
#
@ -567,6 +623,34 @@ class PrintScheduler:
# unsuccessful exit; a successful start removes the item id and leaves
# the reservation for finance_billing to consume with the archive.
self._unconfirmed_budget_reservations: set[int] = set()
# Chamber temperature history for smart soak-time reduction.
# printer_id -> deque of (monotonic_timestamp, celsius) sampled each scheduler tick.
# Entries older than _chamber_history_ttl are pruned on write.
self._chamber_history: dict[int, deque[tuple[float, float]]] = {}
self._chamber_history_ttl = _CHAMBER_HISTORY_TTL_SECONDS
# Per-printer keep-warm state (see `_KeepWarmEntry` at module top).
# Populated on engagement in `_apply_keep_warm`, cleared by
# `_sweep_keep_warm` when the printer leaves the candidate set (or
# when a gate setting toggles off mid-hold — the release publishes
# bed → 0 first).
self._keep_warm: dict[int, _KeepWarmEntry] = {}
# Preheat rollback registry: printer_id -> subset of
# {"bed", "chamber", "airduct"} listing which preheat commands
# actually fired for the in-flight dispatch. `_dispatch_one` unwinds
# every entry still present at exit unless the print successfully
# started, so a failed upload / cancel / exception never leaves the
# printer heating for a job that isn't happening.
self._preheat_pin: dict[int, set[str]] = {}
# Bed target (°C) that the pinned "bed" entry above actually set, so the
# rollback can tell its own target from one someone else has since
# chosen — the same guard `_release_keep_warm` applies to a keep-warm
# hold. Written wherever `"bed"` joins the pin, evicted alongside it.
self._preheat_pin_bed: dict[int, int] = {}
# Item ids whose in-flight dispatch has been cancelled or deleted while
# its preheat was still holding at temperature. Set by
# `notify_dispatch_cancelled` from the queue routes, consumed by
# `_preheat_sleep`, and cleared when the dispatch exits.
self._cancelled_dispatches: set[int] = set()
async def run(self):
"""Main loop - check queue every interval."""
@ -578,6 +662,7 @@ class PrintScheduler:
while self._running:
dispatched = False
try:
self._sample_chamber_temps()
# No-op while any upload is in flight; on a quiet tick it releases
# a claim whose best-effort clear failed (e.g. the database was
# briefly unreachable), instead of leaving the row wedged until
@ -718,6 +803,14 @@ class PrintScheduler:
# so it must not be auto-dried in the gap before the row flips to
# printing. Report the pass as productive while uploads run so the
# loop stays on the fast interval.
#
# Also release any keep-warm holds that got orphaned by the queue
# emptying — the normal sweep in `_apply_keep_warm` is skipped by
# this early return, so call it directly with an empty candidate
# set. Without this, a printer whose queued item was cancelled or
# deleted would keep its bed at target until the max-duration
# timeout expired.
self._sweep_keep_warm(active_candidates=set(), dispatched=set())
inflight_printers = {pid for (_task, pid) in self._inflight.values() if pid is not None}
await self._check_auto_drying(db, [], inflight_printers)
return bool(self._inflight)
@ -1295,6 +1388,18 @@ class PrintScheduler:
awaiting,
)
# Keep-warm is a comfort feature; dispatch is not. It sits between
# selection and `_launch_uploads`, so anything raising here would
# discard this tick's selections — computed AMS mappings and all —
# and, on a persistent fault, stop the queue dispatching entirely.
# Same reasoning as the deficit check's guard below: never let an
# auxiliary check wedge the queue. The bed simply stays wherever it
# was, and the next tick tries again.
try:
await self._apply_keep_warm(db, items, dispatch_ids, busy_printers, require_plate_clear)
except Exception as e:
logger.warning("Keep-warm pass failed, continuing with dispatch: %s", e, exc_info=True)
# Read the concurrency limit BEFORE the commit below, not inside
# _dispatch_selected(). A SELECT on this session after the commit
# implicitly opens a fresh transaction that nothing then closes, and
@ -1379,20 +1484,29 @@ class PrintScheduler:
)
for item_id in to_launch:
task = spawn_background_task(self._dispatch_one(item_id), name=f"queue-upload-{item_id}")
task = spawn_background_task(
self._dispatch_one(item_id, item_printers.get(item_id)),
name=f"queue-upload-{item_id}",
)
self._inflight[item_id] = (task, item_printers.get(item_id))
# Prune on completion so the freed slot is refillable next tick.
# spawn_background_task already logs any uncaught exception; this
# only reclaims the pool slot (fires on success, failure, or cancel).
task.add_done_callback(lambda _t, iid=item_id: self._inflight.pop(iid, None))
async def _dispatch_one(self, item_id: int) -> None:
async def _dispatch_one(self, item_id: int, selected_printer_id: int | None = None) -> None:
"""Upload + start one queue item in its own session (pool worker, #2602).
Its own session: pool workers run concurrently and an AsyncSession is
not safe to share across tasks; it also keeps a slow upload from pinning
the scheduler's session (and, on SQLite, its transaction) open for the
transfer's duration.
``selected_printer_id`` is the printer this item was selected for, taken
from the same snapshot the caller used. It exists so the preheat pin can
be unwound on the paths that never reach the ``finally`` below see the
claim failure a few lines down. Optional so the direct-call tests keep
working; when it is absent those paths simply behave as they did before.
"""
async with async_session() as item_db:
# Claim the row for dispatch BEFORE reading the printer snapshot or
@ -1406,12 +1520,29 @@ class PrintScheduler:
"Queue item %s not claimable for dispatch (cancelled, removed, or already claimed) — skipping",
item_id,
)
# This return is outside the try/finally below, so the rollback
# has to happen here. Selecting this item already handed any
# keep-warm hold on its printer over to the preheat pin
# (`_sweep_keep_warm`), on the promise that this dispatch would
# unwind it. Bailing without doing so leaves the bed hot with
# nothing tracking it: the keep-warm entry is gone, so the
# max-duration cap no longer applies, and if this was the
# printer's last pending item nothing else will ever turn it
# off. Reachable whenever a cancel or delete lands between
# selection and the claim.
if selected_printer_id is not None:
self._rollback_preheat_pin(item_id, selected_printer_id)
return
# Seeded from the caller's snapshot so the `item vanished` return
# below still unwinds the pin; overwritten with the row's own
# printer_id as soon as we have it.
item_printer_id: int | None = selected_printer_id
try:
item = await item_db.get(PrintQueueItem, item_id)
if not item:
logger.info("Queue item %s vanished after claim — skipping", item_id)
return
item_printer_id = item.printer_id
await self._start_print(item_db, item)
finally:
# Undo an expected-print registration whose print command never
@ -1427,6 +1558,18 @@ class PrintScheduler:
# command. Failure, cancellation, deferral, and exceptions all
# release it here.
await asyncio.shield(self._release_unconfirmed_budget_reservation(item_id))
# Unwind preheat state (bed/chamber/airduct) if the
# dispatch aborted before the print's own gcode took over.
# `_start_print` clears the pin on successful `start_print()`;
# anything still present here is by definition an aborted
# dispatch and gets rolled back so the printer isn't left
# heating for a job that isn't happening.
if item_printer_id is not None:
self._rollback_preheat_pin(item_id, item_printer_id)
# The cancellation flag only has meaning while this dispatch is
# running; drop it so the set cannot grow without bound and a
# re-queued item never inherits a stale cancellation.
self._cancelled_dispatches.discard(item_id)
# Release the claim on every exit. Once dispatch has finished the
# row's status carries the lock (printing/failed/cancelled are all
# != pending), so the token is only needed for the duration of the
@ -1490,6 +1633,99 @@ class PrintScheduler:
return
await asyncio.sleep(0.5 * attempt)
@staticmethod
def _reported_bed_target(printer_id: int) -> int | None:
"""The bed target firmware currently reports, or None if it can't be read.
None means "no evidence", not "zero" callers must not treat it as a
temperature. Deliberately total: this feeds cleanup paths that run in a
``finally``, where a malformed status must not become the exception the
caller sees.
"""
try:
state = printer_manager.get_status(printer_id)
if state is None:
return None
temps = state.temperatures
if not isinstance(temps, dict):
return None
return int(float(temps.get("bed_target", 0) or 0))
except (TypeError, ValueError, AttributeError):
return None
def _rollback_preheat_pin(self, item_id: int, printer_id: int) -> None:
"""Unwind everything preheat set when dispatch did NOT hand off to a running print.
Turns the bed heater off, the chamber heater off, and opens the
airduct flap back to cooling for whichever of those preheat
actually applied. `_start_print` clears the pin on successful
`start_print()`; anything still present when `_dispatch_one` exits
is by definition an aborted dispatch and gets rolled back here.
The bed is the one action that can be declined: if firmware has since
been given a target other than the one we pinned, it belongs to someone
else and is left alone. See the comment at that branch.
Also called directly from `_dispatch_one`'s claim-failure return, which
never reaches the ``finally``.
Best-effort and never raises this runs in the ``finally`` of dispatch.
"""
pin = self._preheat_pin.pop(printer_id, set())
pinned_bed = self._preheat_pin_bed.pop(printer_id, None)
if not pin:
return
client = printer_manager.get_client(printer_id)
if client is None:
logger.info(
"Dispatch item %s (printer %d): preheat rollback skipped — no client",
item_id,
printer_id,
)
return
if "bed" in pin:
# Only undo our own target. If firmware reports something else, the
# user or another writer owns the bed now and zeroing it would
# clobber their choice -- the same guard `_release_keep_warm`
# applies to a keep-warm hold.
#
# Every uncertain case switches the bed off rather than leaving it:
# no recorded target (a pin written before this bookkeeping, or a
# setter that raised after pinning) and an unreadable status both
# fall through. A bed left hot with no owner is the worse failure,
# and this runs in a `finally` where raising would mask the real
# exception.
cur_bed_target = self._reported_bed_target(printer_id) if pinned_bed is not None else None
if cur_bed_target is not None and cur_bed_target != pinned_bed:
logger.info(
"Dispatch item %s (printer %d): rollback skipped bed → 0 (firmware target %d != pinned %d)",
item_id,
printer_id,
cur_bed_target,
pinned_bed,
)
else:
try:
client.set_bed_temperature(0)
except Exception as exc:
logger.warning("Dispatch item %s: rollback bed → 0 failed: %s", item_id, exc)
if "chamber" in pin:
try:
client.set_chamber_temperature(0)
except Exception as exc:
logger.warning("Dispatch item %s: rollback chamber → 0 failed: %s", item_id, exc)
if "airduct" in pin:
try:
client.set_airduct_mode("cooling")
except Exception as exc:
logger.warning("Dispatch item %s: rollback airduct → cooling failed: %s", item_id, exc)
logger.info(
"Dispatch item %s (printer %d): preheat rollback → %s",
item_id,
printer_id,
sorted(pin),
)
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
@ -3692,15 +3928,428 @@ class PrintScheduler:
best = target
return best
def _release_keep_warm(self, pid: int) -> None:
"""Release keep-warm on a printer that left the candidate set.
Publishes ``set_bed_temperature(0)`` once but only if firmware still
reports the target we set (``entry.held_target``), so a user or
subsequent print that changed the bed target since is not clobbered.
Best-effort, never raises.
The entry is kept, not dropped, when the printer cannot be reached
right now: a printer that is briefly offline still has a hot bed, and
holding the entry is what keeps the max-duration timeout applying and
lets a later tick retry the release. Only a printer that has left the
manager entirely gives up on that, in ``_sample_chamber_temps``.
"""
entry = self._keep_warm.get(pid)
if entry is None:
return
state = printer_manager.get_status(pid)
client = printer_manager.get_client(pid)
if state is None or client is None:
logger.debug(
"Queue: keep-warm release for printer %d deferred — printer unreachable, entry kept",
pid,
)
return
cur_bed_target = float((state.temperatures or {}).get("bed_target", 0) or 0)
if int(cur_bed_target) != entry.held_target:
# Someone else owns the bed now, so there is nothing of ours to
# undo and nothing left to track.
logger.info(
"Queue: keep-warm release for printer %d skipped bed-off (firmware target %d != held %d)",
pid,
int(cur_bed_target),
entry.held_target,
)
self._keep_warm.pop(pid, None)
return
try:
client.set_bed_temperature(0)
logger.info("Queue: keep-warm released for printer %d (bed → 0)", pid)
self._keep_warm.pop(pid, None)
except Exception as exc:
# Keep the entry so the next tick tries again rather than leaving
# the bed hot with nothing tracking it.
logger.warning("Queue: keep-warm release for printer %d failed: %s", pid, exc)
def _sweep_keep_warm(self, active_candidates: set[int], dispatched: set[int]) -> None:
"""Release printers that dropped out of the keep-warm candidate set.
Called from ``_apply_keep_warm`` on every tick (with the current
candidate set), and from ``check_queue``'s no-pending-items early
return (with an empty candidate set) so orphaned holds still get
released when the queue empties. Also called with an empty candidate
set when any of the three gate settings toggles off, so a printer
whose feature was disabled mid-hold gets its bed released.
Printers being dispatched this tick are excluded from the bed-off
publish: ``_preheat_and_soak`` owns the bed from that tick on, so a
transient 0 in between would just churn against preheat. Ownership of
the hot bed transfers to the preheat rollback pin instead if the
dispatch aborts before the print starts (failed upload, cancelled
item), `_rollback_preheat_pin` turns the bed off; if preheat itself
skips (e.g. the item has no bed_temperature metadata) the pin entry
is the ONLY thing standing between an aborted dispatch and a bed
left hot with no owner. A successful print start clears the pin and
the print's own gcode takes over, as usual.
"""
for _pid in list(self._keep_warm):
if _pid in active_candidates:
continue
if _pid in dispatched:
handed_over = self._keep_warm.pop(_pid, None)
self._preheat_pin.setdefault(_pid, set()).add("bed")
if handed_over is not None:
self._preheat_pin_bed[_pid] = handed_over.held_target
continue
self._release_keep_warm(_pid)
async def _apply_keep_warm(
self,
db: AsyncSession,
items: list[PrintQueueItem],
dispatch_ids: list[int] | set[int],
busy_printers: set[int],
require_plate_clear: bool,
) -> None:
"""Hold the bed warm on FINISH printers whose next queued item needs chamber heat.
When a printer just finished a job (FINISH state) and the next queued
item needs chamber heating, hold the bed hot so the chamber stays warm
during the bed-clearing window. The bed is the chamber's heating
element here, not a print surface nothing is printing during the
hold and the dispatched print's own preheat/gcode re-targets the bed —
so the hold temperature is ``queue_keep_warm_bed_temp`` (default 90°C,
chosen to sustain chamber warmth and to satisfy bed-threshold-linked
aftermarket chamber heaters), raised to the item's own parsed
bed_temperature when that is higher. Items whose archive metadata has
no bed temperature (e.g. OrcaSlicer gcode.3mf exports) therefore still
get a hold chamber need is what gates the feature, not metadata.
Skips entirely for filaments that map to a 0°C chamber target
(PLA, PETG, etc.). Printers being dispatched this cycle are excluded:
``_preheat_and_soak`` already handles their bed temperature.
Bounded by ``queue_keep_warm_max_minutes`` on timeout the bed is
released to 0 and the entry is latched ``expired=True`` so
subsequent ticks neither re-engage nor re-seed the clock. Idempotent
MQTT: publish is skipped when firmware already has the target.
The release sweep runs BEFORE the engagement gate so a printer that
was owned by keep-warm still gets its bed released when any of the
three gate settings is toggled off mid-hold. The
``check_queue`` early-return-when-no-items path also calls
``_sweep_keep_warm`` directly to release orphaned holds.
"""
dispatch_set = set(dispatch_ids)
dispatched_printers = {it.printer_id for it in items if it.id in dispatch_set and it.printer_id}
pending_printer_ids = {it.printer_id for it in items if it.printer_id}
warm_candidates = (pending_printer_ids & busy_printers) - dispatched_printers
keep_warm_enabled = await self._get_bool_setting(db, "queue_keep_bed_warm", default=False)
preheat_on = await self._get_bool_setting(db, "preheat_enabled", default=False)
gate_open = keep_warm_enabled and require_plate_clear and preheat_on
# Release sweep first — must run even when gate_open is False so a
# printer owned by keep-warm when a gate toggles off gets released.
self._sweep_keep_warm(
active_candidates=warm_candidates if gate_open else set(),
dispatched=dispatched_printers,
)
if not gate_open:
return
hold_temp = await self._get_int_setting(db, "queue_keep_warm_bed_temp", default=90)
max_hold_seconds = (
await self._get_int_setting(db, "queue_keep_warm_max_minutes", default=_KEEP_WARM_MAX_MINUTES_DEFAULT) * 60
)
now_mono = time.monotonic()
filament_targets: dict[str, int] | None = None
for pid in warm_candidates:
entry = self._keep_warm.get(pid)
# Latched-expired: max-duration timeout already fired for this
# printer. Skip until the release sweep drops the entry (i.e.
# until the printer leaves the candidate set).
if entry is not None and entry.expired:
continue
# These two guards sit ahead of the max-duration check below, so an
# engaged hold only ages out while its printer is still reachable
# and still in FINISH. That is deliberate rather than a hole: with
# no status or no client there is no M140 to send anyway, and the
# elapsed check runs off `entry.started` so it fires on the first
# tick after the printer comes back. Leaving FINISH means the plate
# was cleared, which drops the printer out of `warm_candidates` and
# hands it to `_release_keep_warm` instead. The invariant worth
# preserving if this is ever reordered: every path out of an
# engaged hold ends in a bed-off, whether by timeout or release.
state = printer_manager.get_status(pid)
if state is None or state.state != "FINISH":
continue
client = printer_manager.get_client(pid)
if client is None:
continue
next_item = next((it for it in items if it.printer_id == pid), None)
if next_item is None:
continue
# Hold temperature: the configured keep-warm temp, raised to the
# item's own bed temp when the metadata reports a higher one. A
# missing bed_temperature (Orca gcode.3mf exports parse without
# one) does NOT skip the hold — chamber need gates the feature.
archive = next_item.archive
item_bed = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
bed_target = max(item_bed, hold_temp)
if bed_target <= 0:
continue
explicit = getattr(next_item, "preheat_chamber_target_override", None)
if explicit is not None:
chamber_needed = int(explicit) > 0
else:
if filament_targets is None:
filament_targets = await self._get_preheat_filament_targets(db)
printer_obj = await self._get_printer(db, pid)
chamber_needed = (
printer_obj is not None and self._derive_chamber_target(printer_obj, filament_targets) > 0
)
if not chamber_needed:
continue
# Seed the timer on first engagement; keep it on subsequent ticks
# (never re-seed — that would defeat the max-duration cap).
if entry is None:
entry = _KeepWarmEntry(started=now_mono, held_target=bed_target)
self._keep_warm[pid] = entry
elapsed = now_mono - entry.started
if elapsed > max_hold_seconds:
# Timeout: publish bed → 0 once (if firmware still holds our
# target) and latch expired. The entry stays until the release
# sweep drops it, preventing the next tick from re-seeding.
logger.warning(
"Queue: keep-warm timeout for printer %d (held for %.0fs) — publishing bed → 0",
pid,
elapsed,
)
cur_bed_target = float((state.temperatures or {}).get("bed_target", 0) or 0)
if int(cur_bed_target) == entry.held_target:
try:
client.set_bed_temperature(0)
except Exception as exc:
logger.warning(
"Queue: keep-warm timeout bed-off failed for printer %d: %s",
pid,
exc,
)
else:
logger.info(
"Queue: keep-warm timeout for printer %d skipped bed-off (firmware target %d != held %d)",
pid,
int(cur_bed_target),
entry.held_target,
)
entry.expired = True
continue
# Idempotence: skip publish when firmware already has our target.
cur_bed_target = float((state.temperatures or {}).get("bed_target", 0) or 0)
if int(cur_bed_target) == bed_target:
entry.held_target = bed_target
continue
try:
client.set_bed_temperature(bed_target)
entry.held_target = bed_target
logger.info(
"Queue: keeping bed warm at %d°C for printer %d (FINISH, next item needs chamber heat)",
bed_target,
pid,
)
except Exception as exc:
logger.warning("Queue: keep-warm bed command failed for printer %d: %s", pid, exc)
def _sample_chamber_temps(self) -> None:
"""Record a chamber temperature sample for every connected printer.
Called once per scheduler tick (every 330 s). Entries older than
_chamber_history_ttl are pruned on each write so the deques stay bounded.
Also evicts per-printer state whose printer_id is no longer registered
(e.g. deleted from the DB), so nothing accumulates for gone printers.
"""
now = time.monotonic()
cutoff = now - self._chamber_history_ttl
statuses = printer_manager.get_all_statuses()
known_pids = set(statuses.keys())
for pid, status in statuses.items():
if status is None or not status.connected:
continue
temps = status.temperatures or {}
chamber = temps.get("chamber")
if chamber is None:
continue
hist = self._chamber_history.setdefault(pid, deque())
hist.append((now, float(chamber)))
while hist and hist[0][0] < cutoff:
hist.popleft()
# Evict state for printers that are no longer registered with the manager.
# This is the one place a keep-warm entry is dropped without releasing
# the bed: the printer is gone from the manager, so there is no client
# left to send M140 to. `_release_keep_warm` deliberately keeps entries
# for printers that are merely unreachable, which is what makes this
# the terminal case rather than a silent leak.
for pid in list(self._chamber_history):
if pid not in known_pids:
self._chamber_history.pop(pid, None)
for pid in list(self._keep_warm):
if pid not in known_pids:
logger.info(
"Queue: dropping keep-warm state for printer %d — no longer registered",
pid,
)
self._keep_warm.pop(pid, None)
for pid in list(self._preheat_pin):
if pid not in known_pids:
self._preheat_pin.pop(pid, None)
self._preheat_pin_bed.pop(pid, None)
def _chamber_soak_remaining(
self,
printer_id: int,
chamber_target: float,
soak_seconds: int,
tolerance: float = 2.0,
) -> int:
"""Return how many seconds of soak time are still needed.
Credits the time the chamber has already spent at temperature against
the configured soak. The credit may not start earlier than any of:
* **The newest sample.** Nothing recent means the printer stopped
reporting mid-observation and the chamber may have cooled unseen, so
the full soak is required. (A 2 h history whose last reading is half
an hour old is not evidence of anything the measured cooling rate
is fast enough to cross the threshold in that time.)
* **The most recent contiguous run of samples.** A gap wider than
``_CHAMBER_SAMPLE_MAX_GAP_SECONDS`` is a disconnect, and time on its
far side is not evidence of temperature.
* **The end of the most recent real dip below the threshold.**
A dip only counts as real once it lasts ``_CHAMBER_DIP_GRACE_SECONDS``
see that constant for the thermal reasoning. A stray low reading is
an artifact, and treating it as cooling would discard a soak that
actually happened.
Returns ``soak_seconds`` when nothing can be credited (no history,
stale history, or the chamber is below the threshold right now) and 0
once the credited time covers the whole soak.
"""
hist = self._chamber_history.get(printer_id)
if not hist:
return soak_seconds
now = time.monotonic()
newest_ts, newest_temp = hist[-1]
if now - newest_ts > _CHAMBER_SAMPLE_MAX_GAP_SECONDS:
return soak_seconds # stale — no fresh evidence to credit
threshold = chamber_target - tolerance
if newest_temp < threshold:
return soak_seconds # below target right now; nothing is soaked
samples = list(hist)
# Earliest point we have unbroken observations for.
credit_from = samples[-1][0]
for i in range(len(samples) - 1, 0, -1):
if samples[i][0] - samples[i - 1][0] > _CHAMBER_SAMPLE_MAX_GAP_SECONDS:
break
credit_from = samples[i - 1][0]
# Pull the credit forward to the end of the last significant dip. Each
# excursion is measured between the in-range readings that bracket it,
# so a lone stray sample is charged one sampling interval rather than
# zero, and the comparison errs towards calling a dip real.
i = 0
while i < len(samples):
if samples[i][1] >= threshold:
i += 1
continue
j = i
while j < len(samples) and samples[j][1] < threshold:
j += 1
# `newest_temp >= threshold` was checked above, so j is in range.
opened_at = samples[i - 1][0] if i > 0 else samples[i][0]
if samples[j][0] - opened_at >= _CHAMBER_DIP_GRACE_SECONDS:
# Credit resumes at the last below-threshold sample rather than
# the first good one after it, so a recovered dip over-credits
# by up to one sampling interval — the opposite lean to the
# bracketing above. Both are bounded by the sample cadence and
# dwarfed by the grace period, so neither is worth the extra
# arithmetic to remove.
credit_from = max(credit_from, samples[j - 1][0])
i = j
return max(0, soak_seconds - int(now - credit_from))
def notify_dispatch_cancelled(self, item_id: int) -> None:
"""Tell an in-flight dispatch that its item no longer wants to print.
Called by the queue's cancel and delete routes. Those only write to the
database, which a dispatch coroutine parked in ``asyncio.sleep`` cannot
observe so preheat would keep heating for the rest of max_wait + soak
(45 minutes at the defaults) and keep the printer in ``busy_printers``,
blocking every other queued item behind a print that is not happening.
Signalling in memory rather than re-reading the row keeps this off the
database entirely: no second session, no transaction held across a long
sleep, and no snapshot staleness deciding whether a print goes ahead.
Bambuddy serves from a single uvicorn process with one scheduler task,
so the route and the dispatch always share this object. The flag is
advisory dropping it (e.g. after a restart) only costs a wasted
preheat, never a wrongly-abandoned print.
Only ids with a dispatch actually in flight are recorded, so the set
stays bounded by the upload pool rather than growing once per cancelled
item for the life of the process. Skipping the rest loses nothing: an
item that is not in flight cannot start heating later either, because
``_claim_for_dispatch`` only claims rows that are still ``pending`` and
the caller has already committed a terminal status (or deleted the row)
before calling this.
"""
if item_id in self._inflight:
self._cancelled_dispatches.add(item_id)
async def _preheat_sleep(self, item_id: int, seconds: float) -> bool:
"""Sleep in slices, returning False as soon as the item stops wanting preheat.
A single long ``asyncio.sleep`` cannot notice a cancellation that lands
while it is parked, so the wait is chopped into
``_PREHEAT_CANCEL_CHECK_SECONDS`` slices with a check after each.
"""
remaining = float(seconds)
while remaining > 0:
slice_secs = min(_PREHEAT_CANCEL_CHECK_SECONDS, remaining)
await asyncio.sleep(slice_secs)
remaining -= slice_secs
if item_id in self._cancelled_dispatches:
return False
return True
async def _preheat_and_soak(
self,
db: AsyncSession,
item: PrintQueueItem,
printer: Printer,
archive: PrintArchive | None,
) -> None:
) -> bool:
"""Run the per-printer preheat + heat-soak stage before FTP upload (#1468).
Returns True when the dispatch should carry on to the upload including
every case where preheat is skipped, since a skipped preheat is not a
reason to abandon the print. Returns False only when the item stopped
wanting to be printed while the stage was waiting (cancelled or
deleted); the caller must then abandon the dispatch, and
``_dispatch_one``'s rollback shuts the heaters off on the way out.
Resolution order:
1. `item.preheat_override` 'off' skips entirely; 'inherit' falls back
to the global `preheat_enabled` setting; 'on' forces the stage on
@ -3732,11 +4381,11 @@ class PrintScheduler:
"""
override = (getattr(item, "preheat_override", None) or "inherit").lower()
if override == "off":
return
return True
if override == "inherit":
enabled = await self._get_bool_setting(db, "preheat_enabled", default=False)
if not enabled:
return
return True
# override == "on" forces the stage on regardless of the global setting.
max_wait = await self._get_int_setting(db, "preheat_max_wait_seconds", default=900)
@ -3761,22 +4410,90 @@ class PrintScheduler:
bed_target = int(archive.bed_temperature) if archive and archive.bed_temperature else 0
if bed_target <= 0:
# No bed temperature in the slicer metadata. When the print needs a
# hot chamber the bed is simply how we heat it, so fall back to the
# configured chamber-heating bed temperature rather than skipping
# the whole stage — otherwise the print starts with a cold chamber,
# which is exactly what preheat exists to prevent. Without a chamber
# requirement there is nothing to preheat *for*, so skip as before
# rather than guess a bed temperature for the print itself.
if chamber_target <= 0:
logger.info(
"Queue item %s: preheat skipped — archive has no bed_temperature metadata and no chamber target",
item.id,
)
return True
bed_target = await self._get_int_setting(db, "queue_keep_warm_bed_temp", default=90)
logger.info(
"Queue item %s: preheat skipped — archive has no bed_temperature metadata",
"Queue item %s: archive has no bed_temperature metadata — heating the bed to "
"%d°C to drive the chamber to %d°C",
item.id,
bed_target,
chamber_target,
)
return
client = printer_manager.get_client(printer.id)
if client is None:
logger.warning("Queue item %s: preheat skipped — printer client unavailable", item.id)
return
return True
model = printer.model or ""
has_heater = supports_chamber_heater(model)
has_sensor = supports_chamber_temp(model)
do_chamber = chamber_target > 0 and (has_heater or has_sensor)
# Fast path: if the chamber has been continuously above target for at
# least soak_seconds and the bed is already at temperature, skip the
# entire preheat stage. Typical case: keep-warm held the bed between
# consecutive same-material prints and the chamber never dropped.
if do_chamber and has_sensor and soak_seconds > 0:
remaining = self._chamber_soak_remaining(printer.id, float(chamber_target), soak_seconds)
if remaining == 0:
cur = printer_manager.get_status(printer.id)
if cur:
cur_temps = cur.temperatures or {}
if (
float(cur_temps.get("bed", 0) or 0) >= bed_target - 2.0
and float(cur_temps.get("chamber", 0) or 0) >= chamber_target - 2.0
):
logger.info(
"Queue item %s: preheat skipped — chamber has been above %d°C for ≥%ds "
"and bed is already at temperature (chamber history fast-path)",
item.id,
chamber_target,
soak_seconds,
)
# Still set targets to prevent cooling during the 3MF upload window.
# Register each successful set in the preheat pin so `_dispatch_one`
# unwinds them on any non-success exit.
pin = self._preheat_pin.setdefault(printer.id, set())
try:
client.set_bed_temperature(bed_target)
pin.add("bed")
self._preheat_pin_bed[printer.id] = bed_target
except Exception as exc:
logger.warning("Queue item %s: fast-path bed M140 failed: %s", item.id, exc)
if supports_airduct(model):
cur_airduct = getattr(cur, "airduct_mode", None)
if cur_airduct != _AIRDUCT_MODE_HEATING:
try:
client.set_airduct_mode("heating")
# Only undo what we can see we replaced. `None`
# means no mode has been observed yet, and
# rolling that back to cooling would assert a
# state the printer never reported.
if cur_airduct == _AIRDUCT_MODE_COOLING:
pin.add("airduct")
except Exception as exc:
logger.warning("Queue item %s: fast-path airduct failed: %s", item.id, exc)
if has_heater:
try:
client.set_chamber_temperature(chamber_target)
pin.add("chamber")
except Exception as exc:
logger.warning("Queue item %s: fast-path chamber M141 failed: %s", item.id, exc)
return True
logger.info(
"Queue item %s: preheat starting — bed=%d°C chamber_target=%d°C (source=%s override=%s "
"model=%s has_heater=%s has_sensor=%s) max_wait=%ds soak=%ds",
@ -3792,14 +4509,23 @@ class PrintScheduler:
soak_seconds,
)
# Preheat rollback registry: everything we set below is recorded here so
# `_dispatch_one`'s finally clause can unwind the whole heating regime
# (bed off, chamber off, airduct back to cooling) on any non-success
# exit. Populated as each command succeeds; consumed and cleared by
# `_dispatch_one`.
pin = self._preheat_pin.setdefault(printer.id, set())
# Dispatch heaters. set_bed_temperature / set_chamber_temperature already
# cache the target locally so the polling reads below see consistent
# state (firmware MQTT echoes lag by ~1s).
try:
client.set_bed_temperature(bed_target)
pin.add("bed")
self._preheat_pin_bed[printer.id] = bed_target
except Exception as exc:
logger.warning("Queue item %s: preheat bed M140 failed: %s", item.id, exc)
return
return True
# Airduct mode (#1468 follow-up). Models with the cooling/heating flap
# (H2C/H2D/H2D Pro/H2S/X2D/P2S) keep the flap whatever the user last
@ -3814,12 +4540,17 @@ class PrintScheduler:
# we want it.
if supports_airduct(model):
desired_airduct = "heating" if chamber_target > 0 else "cooling"
desired_id = 1 if desired_airduct == "heating" else 0
desired_id = _AIRDUCT_MODE_HEATING if desired_airduct == "heating" else _AIRDUCT_MODE_COOLING
current_state = printer_manager.get_status(printer.id)
current_airduct = getattr(current_state, "airduct_mode", None) if current_state else None
if current_airduct != desired_id:
try:
client.set_airduct_mode(desired_airduct)
# As in the fast path: only pin a rollback for a flap we
# saw in cooling. `current_airduct` of None means no mode
# has been observed, so there is nothing to restore to.
if desired_airduct == "heating" and current_airduct == _AIRDUCT_MODE_COOLING:
pin.add("airduct")
except Exception as exc:
logger.warning(
"Queue item %s: preheat airduct %s mode failed: %s",
@ -3831,6 +4562,7 @@ class PrintScheduler:
if do_chamber and has_heater:
try:
client.set_chamber_temperature(chamber_target)
pin.add("chamber")
except Exception as exc:
logger.warning("Queue item %s: preheat chamber M141 failed: %s", item.id, exc)
@ -3893,13 +4625,43 @@ class PrintScheduler:
)
break
await asyncio.sleep(POLL_INTERVAL)
if not await self._preheat_sleep(item.id, POLL_INTERVAL):
logger.info(
"Queue item %s: preheat aborted — item cancelled or deleted while waiting for temperature",
item.id,
)
return False
if soak_seconds > 0:
logger.info("Queue item %s: preheat soak — holding for %ds", item.id, soak_seconds)
await asyncio.sleep(soak_seconds)
if do_chamber and has_sensor:
remaining = self._chamber_soak_remaining(printer.id, float(chamber_target), soak_seconds)
else:
remaining = soak_seconds # no sensor — can't verify history, run full soak
if remaining > 0:
logger.info(
"Queue item %s: preheat soak — holding for %ds (of %ds configured; chamber "
"has been above target for ~%ds already)",
item.id,
remaining,
soak_seconds,
soak_seconds - remaining,
)
if not await self._preheat_sleep(item.id, remaining):
logger.info(
"Queue item %s: preheat aborted — item cancelled or deleted during soak",
item.id,
)
return False
else:
logger.info(
"Queue item %s: preheat soak skipped — chamber has been above %d°C for ≥%ds",
item.id,
chamber_target,
soak_seconds,
)
logger.info("Queue item %s: preheat complete — proceeding to upload", item.id)
return True
async def _power_on_and_wait(self, plug: SmartPlug, printer_id: int, db: AsyncSession) -> bool:
"""Turn on smart plug and wait for printer to connect.
@ -4470,7 +5232,13 @@ class PrintScheduler:
# starts the actual print routine. Best-effort: any failure logs and
# falls through to the normal upload+start path rather than turning a
# configuration issue into a failed queue item.
await self._preheat_and_soak(db, item, printer, archive)
# Returns False only when the item was cancelled or deleted while the
# stage was holding at temperature. Uploading and starting it anyway
# would print a job the user has already called off, so abandon the
# dispatch here; `_dispatch_one`'s finally clause unwinds the heaters.
if not await self._preheat_and_soak(db, item, printer, archive):
logger.info("Queue item %s: dispatch abandoned — cancelled during preheat", item.id)
return
# G-code injection for auto-print systems (#422)
injected_path = None
@ -4843,6 +5611,12 @@ class PrintScheduler:
# rolled back.
self._unconfirmed_expected_print.pop(item.id, None)
self._unconfirmed_budget_reservations.discard(item.id)
# Handoff to the print's own gcode: keep whatever preheat set (bed
# target, chamber target, airduct heating) — the gcode owns
# heater/flap control from here. Clearing the pin prevents
# `_dispatch_one`'s finally from unwinding a live print.
self._preheat_pin.pop(item.printer_id, None)
self._preheat_pin_bed.pop(item.printer_id, None)
logger.info("Queue item %s: Print started successfully - %s", item.id, filename)
# No dispatch-toast event here: the legacy bg-dispatch path kept
# status='processing' from upload start until the printer acked

View file

@ -0,0 +1,514 @@
"""Tests for chamber-soak history tracking and smart soak-time reduction.
`_chamber_soak_remaining()` scans a per-printer deque of
(monotonic_timestamp, celsius) samples and returns how many soak seconds
are still needed, crediting time the chamber has already spent above the
target threshold. Real samples arrive every 330 s while a printer is
connected; tests use `_dense_history` to model that cadence, or `_history`
(sparse) when specifically exercising gap-detection behaviour.
Key invariants:
- Empty history full soak (conservative)
- Chamber never dipped, contiguous run < soak credit the run's span
- Chamber never dipped, contiguous run soak skip (return 0)
- Chamber dipped credit only time since last below-threshold sample
- Chamber currently below full soak (time_above 0)
- Gap in samples larger than the cadence threshold credit only the
last contiguous run (disconnect must not be counted as time at temp)
`_sample_chamber_temps()` records one sample per connected printer per
tick, prunes entries older than the 2 h TTL, and evicts per-printer state
whose printer_id disappeared from the manager (printer deleted).
"""
from collections import deque
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from backend.app.services.print_scheduler import (
_CHAMBER_HISTORY_TTL_SECONDS,
_CHAMBER_SAMPLE_MAX_GAP_SECONDS,
PrintScheduler,
)
SOAK = 1800 # seconds (30 min, the typical configured value)
TARGET = 50.0 # °C
PRINTER_ID = 1
NOW = 10_000.0
@pytest.fixture
def scheduler():
return PrintScheduler()
def _history(*entries):
"""Build a deque of (monotonic_ts, celsius) from sparse offset-celsius pairs.
Offsets are relative to NOW (negative = seconds before now). Use this
directly when the test needs an explicit gap between samples
(disconnect/reconnect scenarios). Otherwise prefer `_dense_history`.
"""
d = deque()
for offset, temp in entries:
d.append((NOW + offset, float(temp)))
return d, NOW
def _dense_history(*entries, interval=30):
"""Build a deque with samples every `interval` seconds between entries,
step-filled with the value of the previous entry. Mirrors the real
sampling cadence, so the contiguity guard sees an unbroken run.
"""
d = deque()
if not entries:
return d, NOW
sorted_entries = sorted(entries, key=lambda e: e[0])
prev_offset, prev_temp = sorted_entries[0]
d.append((NOW + prev_offset, float(prev_temp)))
for offset, temp in sorted_entries[1:]:
cur = prev_offset + interval
while cur < offset:
d.append((NOW + cur, float(prev_temp)))
cur += interval
d.append((NOW + offset, float(temp)))
prev_offset, prev_temp = offset, temp
return d, NOW
# ---------------------------------------------------------------------------
# No history
# ---------------------------------------------------------------------------
def test_empty_history_returns_full_soak(scheduler):
"""No samples at all → conservative: return configured soak in full."""
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = NOW
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK
# ---------------------------------------------------------------------------
# Chamber never dropped below threshold — contiguous run credit
# ---------------------------------------------------------------------------
def test_history_shorter_than_soak_credits_span(scheduler):
"""Chamber above target for 600 s of contiguous samples.
Old behaviour returned full soak (wrong). New behaviour credits the
600 s we have evidence for remaining = 1800 - 600 = 1200 s.
"""
hist, now = _dense_history((-600, 55), (0, 53))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK - 600
def test_history_equal_to_soak_returns_zero(scheduler):
"""Chamber above target for exactly soak_seconds → remaining = 0."""
hist, now = _dense_history((-SOAK, 55), (0, 52))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
def test_history_longer_than_soak_returns_zero(scheduler):
"""Chamber above target for longer than soak_seconds → skip entirely."""
hist, now = _dense_history((-3600, 56), (0, 52))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
# ---------------------------------------------------------------------------
# Chamber dipped below threshold at some point
# ---------------------------------------------------------------------------
def test_recent_dip_credits_only_time_since_dip(scheduler):
"""A real cooldown (10 min below threshold) restarts the credit at its end.
Samples run at the real 30 s cadence: hot until -1500 s, below threshold
from -1500 s to -900 s, hot again from -870 s. Credit starts at the last
below-threshold sample (-900 s), so remaining = 1800 - 900 = 900.
"""
hist, now = _dense_history((-3000, 55), (-1500, 44), (-870, 55), (0, 52))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK - 900
def test_dip_long_enough_ago_returns_zero(scheduler):
"""A real cooldown that ended longer ago than the soak → fully credited → 0."""
hist, now = _dense_history((-4000, 55), (-2600, 44), (-1970, 55), (0, 52))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
# ---------------------------------------------------------------------------
# Dip debounce: brief sub-threshold readings are artifacts, not lost soak
# ---------------------------------------------------------------------------
def test_brief_dip_does_not_reset_credit(scheduler):
"""A single stray low sample must not discard hours of accumulated soak.
The chamber cannot physically lose and regain 8°C in one sampling interval
(measured: ~0.2 C/min), so this is a sensor artifact. Crediting from before
the blip leaves the full hour, i.e. no soak needed.
"""
hist, now = _dense_history((-3600, 55), (-600, 47), (-540, 55), (0, 55))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
def test_four_minute_door_open_dip_does_not_reset_credit(scheduler):
"""The real-world case: opening the door to clear the plate.
Modelled on an excursion actually recorded on an X1C roughly four minutes
below threshold, bottoming one degree under it, then straight back. That is
air exchange, not the chamber mass cooling, so the soak still counts.
"""
hist, now = _dense_history((-3600, 55), (-900, 47), (-660, 55), (0, 55))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
def test_dip_past_grace_period_does_reset_credit(scheduler):
"""An excursion longer than the grace is real cooling and does reset it.
Guards the other side of the debounce: 25 minutes below threshold is far
slower than any artifact and well within the measured cooling rate, so the
credit restarts at the end of the dip (-1530 s) 1800 - 1530 = 270.
"""
hist, now = _dense_history((-5000, 55), (-3000, 45), (-1500, 55), (0, 55))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK - 1530
# ---------------------------------------------------------------------------
# Freshness: an old history is not evidence about the chamber right now
# ---------------------------------------------------------------------------
def test_stale_history_requires_full_soak(scheduler):
"""Hot history whose newest sample predates the max gap → full soak.
The printer stopped reporting; at the measured cooling rate the chamber can
cross the threshold inside such a window, so nothing may be credited.
"""
hist, _ = _dense_history((-7200, 55), (-1800, 55))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = NOW
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK
def test_fresh_history_within_max_gap_is_credited(scheduler):
"""Boundary partner: a newest sample inside the max gap still counts."""
hist, _ = _dense_history((-7200, 55), (-30, 55))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = NOW
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
def test_currently_below_threshold_returns_full_soak(scheduler):
"""Most recent sample is below threshold → time_above ≈ 0 → full soak."""
hist, now = _history(
(-600, 55),
(-300, 52),
(0, 45), # BELOW threshold right now
)
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK
# ---------------------------------------------------------------------------
# Contiguity / gap handling in the no-dip branch
# ---------------------------------------------------------------------------
def test_disconnect_gap_credits_only_last_contiguous_run(scheduler):
"""Chamber above threshold both before AND after a big gap in samples.
Simulates a printer that was hot, disconnected for 30 min, and came back
still hot. We cannot claim it was at temperature during the disconnect
only the most recent contiguous run counts. Credit = 600 s (post-gap
run), remaining = 1800 - 600 = 1200.
"""
pre_gap, _ = _dense_history((-3000, 55), (-2000, 55))
post_gap, now = _dense_history((-600, 55), (0, 55))
hist = deque(list(pre_gap) + list(post_gap))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK - 600
def test_sample_gap_at_cadence_threshold_still_contiguous(scheduler):
"""A gap exactly at the max-gap threshold does NOT break contiguity.
The check is strictly greater-than, so a gap == threshold still credits
across it. Guards against off-by-one drift in the contiguity heuristic.
"""
hist, now = _history(
(-1800, 55),
(-1800 + int(_CHAMBER_SAMPLE_MAX_GAP_SECONDS), 55), # gap = threshold exactly
(0, 55),
)
# Fill densely from the second entry onwards so only the first-to-second
# gap is at the threshold.
dense_tail, _ = _dense_history(
(-1800 + int(_CHAMBER_SAMPLE_MAX_GAP_SECONDS), 55),
(0, 55),
)
hist = deque([hist[0]] + list(dense_tail))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
# ---------------------------------------------------------------------------
# Tolerance boundary
# ---------------------------------------------------------------------------
def test_tolerance_boundary_above_counts_as_above(scheduler):
"""Sample at target - tolerance + 0.1 is above threshold → credit."""
threshold_plus = TARGET - 2.0 + 0.1 # 48.1°C — just above threshold
hist, now = _dense_history((-SOAK, threshold_plus), (0, threshold_plus))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
def test_tolerance_boundary_at_threshold_counts_as_above(scheduler):
"""Sample exactly AT target - tolerance is NOT below (strictly less-than).
Three contiguous samples 30 s apart: 55, 48.0, 55. The middle sample sits
exactly at the threshold (48.0). If the at-threshold check counted as
'below', last_below_ts would fire on the middle sample and remaining
would be SOAK - 30 = 1770. Because the check is strict ``temp < threshold``
(and 48.0 < 48.0 is False), no dip is found the whole 60 s contiguous
span is credited and remaining = SOAK - 60 = 1740.
Distinguishing the two branches is the point: the OLD test compared
against 0 no matter which branch fired.
"""
at_threshold = TARGET - 2.0 # 48.0°C
hist, now = _history((-60, 55), (-30, at_threshold), (0, 55))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == SOAK - 60
# ---------------------------------------------------------------------------
# Result is always non-negative
# ---------------------------------------------------------------------------
def test_result_never_negative(scheduler):
"""Even if the contiguous run spans many times the soak duration, floors at 0."""
hist, now = _dense_history((-7200, 55), (0, 52))
scheduler._chamber_history[PRINTER_ID] = hist
with patch("backend.app.services.print_scheduler.time") as t:
t.monotonic.return_value = now
result = scheduler._chamber_soak_remaining(PRINTER_ID, TARGET, SOAK)
assert result == 0
# ---------------------------------------------------------------------------
# _sample_chamber_temps: recording, TTL, gating, eviction
# ---------------------------------------------------------------------------
def _status(*, connected=True, chamber=None, bed=None):
"""Build a PrinterStatus-shaped namespace. `chamber=None` → key absent."""
temps: dict = {}
if chamber is not None:
temps["chamber"] = chamber
if bed is not None:
temps["bed"] = bed
return SimpleNamespace(connected=connected, temperatures=temps)
def test_sample_chamber_temps_appends_current_reading(scheduler):
"""Each tick appends one (now, chamber_temp) sample per connected printer."""
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=52.5)}
scheduler._sample_chamber_temps()
hist = scheduler._chamber_history[PRINTER_ID]
assert list(hist) == [(NOW, 52.5)]
def test_sample_chamber_temps_prunes_entries_beyond_ttl(scheduler):
"""Samples older than _CHAMBER_HISTORY_TTL_SECONDS are popped from the deque."""
old = NOW - _CHAMBER_HISTORY_TTL_SECONDS - 100
recent = NOW - 30
scheduler._chamber_history[PRINTER_ID] = deque([(old, 55.0), (recent, 55.0)])
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=55.0)}
scheduler._sample_chamber_temps()
ts_values = [entry[0] for entry in scheduler._chamber_history[PRINTER_ID]]
assert old not in ts_values
assert recent in ts_values
def test_sample_chamber_temps_skips_absent_chamber_key(scheduler):
"""No 'chamber' key (e.g. printer without chamber sensor) → no sample recorded."""
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: _status(bed=60.0)} # no chamber
scheduler._sample_chamber_temps()
assert PRINTER_ID not in scheduler._chamber_history
def test_sample_chamber_temps_skips_disconnected_printer(scheduler):
"""A registered but disconnected printer keeps stale temps → don't sample it."""
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: _status(connected=False, chamber=55.0)}
scheduler._sample_chamber_temps()
assert PRINTER_ID not in scheduler._chamber_history
def test_sample_chamber_temps_evicts_history_for_removed_printer(scheduler):
"""A printer_id present in _chamber_history but not in the manager → evicted."""
scheduler._chamber_history[99] = deque([(NOW - 100, 55.0)])
scheduler._chamber_history[PRINTER_ID] = deque([(NOW - 100, 55.0)])
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=55.0)}
scheduler._sample_chamber_temps()
assert 99 not in scheduler._chamber_history
assert PRINTER_ID in scheduler._chamber_history
def test_sample_chamber_temps_evicts_keep_warm_state_for_removed_printer(scheduler):
"""A printer_id in _keep_warm but not in the manager → evicted."""
from backend.app.services.print_scheduler import _KeepWarmEntry
scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 100, held_target=100)
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 100, held_target=100)
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: _status(chamber=55.0)}
scheduler._sample_chamber_temps()
assert 99 not in scheduler._keep_warm
assert PRINTER_ID in scheduler._keep_warm
def test_sample_chamber_temps_none_status_ignored(scheduler):
"""get_all_statuses() can return None entries — those must not crash sampling.
The None-check must run BEFORE `status.connected` is dereferenced, or an
unregistered / mid-shutdown entry will AttributeError the whole tick.
"""
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {2: None, PRINTER_ID: _status(chamber=55.0)}
scheduler._sample_chamber_temps()
assert 2 not in scheduler._chamber_history
assert PRINTER_ID in scheduler._chamber_history

View file

@ -0,0 +1,899 @@
"""Tests for the keep-bed-warm loop that fires between queued prints.
`_apply_keep_warm()` is the per-tick helper that holds the bed hot on a
printer sitting in FINISH awaiting a plate-clear so the chamber does not
cool down between back-to-back chamber-heated prints.
The hold temperature is `queue_keep_warm_bed_temp` (default 90 °C), raised to
the next item's own parsed bed_temperature when that is higher. The bed is
the chamber's heat source here, not a print surface, so an item with no
bed_temperature metadata still gets a hold what gates the feature is
whether the next print needs chamber heat.
Gates the whole block on three settings AND-ed together
(`queue_keep_bed_warm`, `require_plate_clear`, `preheat_enabled`) so a user
who turns off the plate-clear or preheat gate stops holding heat without
having to also toggle keep-warm. Bounded by `queue_keep_warm_max_minutes`, and
skips the MQTT publish when the firmware already has the target.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from backend.app.services.print_scheduler import (
PrintScheduler,
_KeepWarmEntry,
)
PRINTER_ID = 7
# Archive bed temperature of the next queued item. Deliberately ABOVE HOLD_TEMP
# so the default fixtures exercise the "item's own bed temp wins" branch.
BED_TARGET = 100
# The configured `queue_keep_warm_bed_temp` floor used by `_run`.
HOLD_TEMP = 90
# The configured `queue_keep_warm_max_minutes` used by `_run`, in minutes and
# the seconds the scheduler derives from it.
MAX_HOLD_MINUTES = 120
MAX_HOLD_SECONDS = MAX_HOLD_MINUTES * 60
NOW = 10_000.0
@pytest.fixture
def scheduler():
return PrintScheduler()
def _make_item(
item_id: int = 1,
printer_id: int = PRINTER_ID,
bed_temperature: int | None = BED_TARGET,
preheat_chamber_target_override: int | None = 60,
):
"""Build a queue-item-shaped namespace with an archive.
``preheat_chamber_target_override`` at a non-zero int makes the chamber-
needed check pass without any AMS-derivation mocking. Set to ``None`` in
tests that specifically want to exercise the derivation branch.
"""
archive = SimpleNamespace(bed_temperature=bed_temperature)
return SimpleNamespace(
id=item_id,
printer_id=printer_id,
archive=archive,
preheat_chamber_target_override=preheat_chamber_target_override,
)
def _make_state(*, state="FINISH", bed_target=0.0, chamber=55.0):
"""PrinterState-shaped namespace with just what the keep-warm loop reads."""
return SimpleNamespace(
state=state,
temperatures={"bed_target": bed_target, "chamber": chamber},
raw_data={},
)
def _make_client():
client = MagicMock()
client.set_bed_temperature = MagicMock(return_value=True)
return client
def _bool_settings(**overrides):
"""AsyncMock side_effect returning per-key bool values.
Defaults enable the full stack; pass ``queue_keep_bed_warm=False`` etc
to switch individual gates off.
"""
defaults = {
"queue_keep_bed_warm": True,
"preheat_enabled": True,
}
defaults.update(overrides)
return AsyncMock(side_effect=lambda _db, key, default: defaults.get(key, default))
def _int_settings(hold_temp, max_hold_minutes):
return {
"queue_keep_warm_bed_temp": hold_temp,
"queue_keep_warm_max_minutes": max_hold_minutes,
}
async def _run(
scheduler,
*,
items=None,
dispatch_ids=None,
busy_printers=None,
require_plate_clear=True,
bool_settings=None,
hold_temp=HOLD_TEMP,
max_hold_minutes=MAX_HOLD_MINUTES,
):
"""Invoke `_apply_keep_warm` with sensible defaults and standard patches."""
if items is None:
items = [_make_item()]
if dispatch_ids is None:
dispatch_ids = []
if busy_printers is None:
busy_printers = {PRINTER_ID}
if bool_settings is None:
bool_settings = _bool_settings()
db = AsyncMock()
with (
patch.object(scheduler, "_get_bool_setting", bool_settings),
patch.object(
scheduler,
"_get_int_setting",
AsyncMock(
side_effect=lambda _db, key, default=0: _int_settings(hold_temp, max_hold_minutes).get(key, default)
),
),
patch("backend.app.services.print_scheduler.time") as t,
):
t.monotonic.return_value = NOW
await scheduler._apply_keep_warm(db, items, dispatch_ids, busy_printers, require_plate_clear)
# ---------------------------------------------------------------------------
# Gating: all three settings AND-ed together
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_skips_when_feature_disabled(scheduler):
"""queue_keep_bed_warm=False → no MQTT publish, no state change."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, bool_settings=_bool_settings(queue_keep_bed_warm=False))
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_skips_when_require_plate_clear_off(scheduler):
"""require_plate_clear=False → skip even if keep-warm and preheat are on."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, require_plate_clear=False)
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_skips_when_preheat_disabled(scheduler):
"""preheat_enabled=False → skip regardless of the toggle."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, bool_settings=_bool_settings(preheat_enabled=False))
client.set_bed_temperature.assert_not_called()
# ---------------------------------------------------------------------------
# Per-printer skip conditions
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_skips_when_printer_not_in_finish(scheduler):
"""Only FINISH printers keep warm — a printer that's still printing is not held."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(state="RUNNING")
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_holds_configured_temp_when_archive_has_no_bed_temp(scheduler):
"""No parsed bed_temperature → still hold, at the configured keep-warm temp.
The bed is the chamber's heat source during the hold, not a print surface,
so missing slicer metadata must not disable the feature. OrcaSlicer
`.gcode.3mf` exports parse without a bed temperature and would otherwise
never keep warm even though their filament requires chamber heat.
"""
client = _make_client()
item = _make_item(bed_temperature=None)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item])
client.set_bed_temperature.assert_called_once_with(HOLD_TEMP)
assert scheduler._keep_warm[PRINTER_ID].held_target == HOLD_TEMP
@pytest.mark.asyncio
async def test_keep_warm_uses_item_bed_temp_when_higher_than_configured(scheduler):
"""Item's own bed temp (100) > configured hold (90) → hold at 100.
The hold must never run cooler than the print itself will, or the chamber
would dip right before dispatch.
"""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(bed_temperature=100)])
client.set_bed_temperature.assert_called_once_with(100)
@pytest.mark.asyncio
async def test_keep_warm_uses_configured_temp_when_item_bed_temp_lower(scheduler):
"""Item's bed temp (60) < configured hold (90) → hold at 90.
A cool-plate ASA profile still needs the chamber hot; the floor wins.
"""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(bed_temperature=60)])
client.set_bed_temperature.assert_called_once_with(HOLD_TEMP)
@pytest.mark.asyncio
async def test_keep_warm_honours_custom_configured_hold_temp(scheduler):
"""`queue_keep_warm_bed_temp` is read from settings, not hard-coded."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(bed_temperature=None)], hold_temp=105)
client.set_bed_temperature.assert_called_once_with(105)
@pytest.mark.asyncio
async def test_keep_warm_skips_when_no_client(scheduler):
"""No live client (e.g. printer just deregistered) → skip silently."""
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = None
await _run(scheduler)
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_skips_when_chamber_override_zero(scheduler):
"""Per-item override of 0 → 'no chamber even if filament wants it' → skip."""
client = _make_client()
item = _make_item(preheat_chamber_target_override=0)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item])
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_skips_when_dispatched_this_cycle(scheduler):
"""Printers being dispatched this tick are excluded — _preheat_and_soak owns their bed."""
client = _make_client()
item = _make_item(item_id=42)
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item], dispatch_ids=[42])
client.set_bed_temperature.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_skips_when_chamber_derivation_yields_zero(scheduler):
"""No per-item override + _derive_chamber_target returns 0 → skip."""
client = _make_client()
item = _make_item(preheat_chamber_target_override=None)
with (
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_get_preheat_filament_targets", AsyncMock(return_value={})),
patch.object(scheduler, "_get_printer", AsyncMock(return_value=SimpleNamespace(id=PRINTER_ID, model="H2D"))),
patch.object(scheduler, "_derive_chamber_target", return_value=0),
):
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler, items=[item])
client.set_bed_temperature.assert_not_called()
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_publishes_bed_target(scheduler):
"""Full-stack happy path: gates on, printer in FINISH, chamber needed → M140 sent."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state() # bed_target=0 → publish fires
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_called_once_with(BED_TARGET)
assert PRINTER_ID in scheduler._keep_warm
entry = scheduler._keep_warm[PRINTER_ID]
assert entry.held_target == BED_TARGET
assert entry.expired is False
# ---------------------------------------------------------------------------
# Idempotence guard
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_skips_when_firmware_already_at_target(scheduler):
"""state.temperatures['bed_target'] already equals the desired target → no publish."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_not_called()
# ---------------------------------------------------------------------------
# Max-duration timeout — publish bed → 0 once, latch expired, do NOT re-arm
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_publishes_bed_off_and_latches_on_timeout(scheduler):
"""After MAX_HOLD_SECONDS: publish bed → 0, latch expired, keep the entry.
The old behaviour popped the entry but that meant the next tick's
``setdefault`` re-seeded ``started`` and the 2 h window restarted forever.
The entry must stay so subsequent ticks skip re-engagement.
"""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (MAX_HOLD_SECONDS + 1),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware still holds our target → bed-off publish fires.
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID in scheduler._keep_warm
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_timeout_does_not_rearm_on_next_tick(scheduler):
"""Multi-tick regression guard: the tick AFTER a timeout must NOT re-engage.
This is the bug the review flagged: popping on timeout let the next
tick's ``setdefault(pid, now_mono)`` re-seed the clock, restarting the
2 h window. Latching ``expired=True`` on the entry (kept in place)
prevents that.
"""
original_started = NOW - (MAX_HOLD_SECONDS + 1)
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=original_started,
held_target=BED_TARGET,
expired=True, # already latched by previous tick's timeout
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
# No re-engagement, no bed-off (already sent in the prior tick), and the
# entry keeps its ORIGINAL started timestamp — no clock re-seed.
client.set_bed_temperature.assert_not_called()
assert scheduler._keep_warm[PRINTER_ID].started == original_started
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_timeout_skips_bed_off_when_firmware_target_changed(scheduler):
"""Firmware bed_target != held_target on timeout → don't clobber user's change."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (MAX_HOLD_SECONDS + 1),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware target no longer matches held_target.
pm.get_status.return_value = _make_state(bed_target=42.0)
pm.get_client.return_value = client
await _run(scheduler)
client.set_bed_temperature.assert_not_called()
# Latch still fires so we don't re-engage next tick.
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_starts_timer_on_first_tick(scheduler):
"""First tick for a printer creates a _KeepWarmEntry with started=NOW."""
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
await _run(scheduler)
assert PRINTER_ID in scheduler._keep_warm
entry = scheduler._keep_warm[PRINTER_ID]
assert entry.started == NOW
assert entry.held_target == BED_TARGET
assert entry.expired is False
@pytest.mark.asyncio
async def test_keep_warm_preserves_existing_timer(scheduler):
"""Subsequent ticks must NOT reset started — otherwise timeout never fires."""
started_earlier = NOW - 3600
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=started_earlier,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware already at our target → idempotence skips the publish;
# the entry is preserved as-is.
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler)
assert scheduler._keep_warm[PRINTER_ID].started == started_earlier
# ---------------------------------------------------------------------------
# Release sweep — bed → 0 when printer leaves the candidate set / gate off
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_releases_bed_when_printer_leaves_candidate_set(scheduler):
"""Owned printer no longer in candidates → publish bed → 0, drop entry."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_release_skipped_when_printer_was_dispatched(scheduler):
"""Dispatched printers exit candidates but _preheat_and_soak owns the bed — no bed-off."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
item = _make_item(item_id=42)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
# Item 42 is being dispatched this tick — release must NOT publish.
await _run(scheduler, items=[item], dispatch_ids=[42])
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._keep_warm # tracking dropped either way
@pytest.mark.asyncio
async def test_keep_warm_hands_bed_ownership_to_preheat_pin_on_dispatch(scheduler):
"""Handing a hot bed to dispatch must register it for preheat rollback.
Keep-warm stops tracking the printer the moment it is dispatched, and
`_preheat_and_soak` may never claim the bed itself (it returns early when
the item has no bed_temperature metadata). Without this transfer, an
aborted dispatch failed upload, cancelled item would leave the bed hot
with no owner and nothing to turn it off.
"""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 300, held_target=BED_TARGET)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[_make_item(item_id=42)], dispatch_ids=[42])
assert "bed" in scheduler._preheat_pin.get(PRINTER_ID, set())
@pytest.mark.asyncio
async def test_keep_warm_release_does_not_touch_preheat_pin(scheduler):
"""A genuine release (not a dispatch) turns the bed off — no pin entry needed."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 300, held_target=BED_TARGET)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._preheat_pin
@pytest.mark.asyncio
async def test_keep_warm_release_skipped_when_firmware_target_changed(scheduler):
"""Firmware bed_target != held_target on release → don't clobber user's change."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=42.0)
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._keep_warm # tracking still dropped
@pytest.mark.asyncio
async def test_keep_warm_release_fires_when_feature_toggled_off_mid_hold(scheduler):
"""queue_keep_bed_warm turned off while a printer is owned → release still fires."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, bool_settings=_bool_settings(queue_keep_bed_warm=False))
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_release_fires_when_plate_clear_toggled_off_mid_hold(scheduler):
"""require_plate_clear=False mid-hold → release still fires."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - 300,
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, require_plate_clear=False)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._keep_warm
# ---------------------------------------------------------------------------
# Candidate-set eviction of stale state
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_keeps_entry_when_printer_is_unreachable(scheduler):
"""An unreachable printer keeps its entry so a later tick can still release it.
Printer 99 left the candidate set, but `get_status` returns None it is
briefly offline, not gone. Its bed may still be hot, so dropping the entry
here would stop the max-duration timeout applying and leave nothing
tracking it. The entry is kept and the release retried later;
`_sample_chamber_temps` is the only place that gives up, once the printer
has left the manager entirely.
"""
scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.side_effect = lambda pid: _make_state(bed_target=float(BED_TARGET)) if pid == PRINTER_ID else None
pm.get_client.side_effect = lambda pid: client if pid == PRINTER_ID else None
await _run(scheduler, busy_printers={PRINTER_ID})
assert 99 in scheduler._keep_warm, "unreachable printer must stay tracked"
assert PRINTER_ID in scheduler._keep_warm
@pytest.mark.asyncio
async def test_keep_warm_release_retries_after_a_failed_publish(scheduler):
"""A failed bed-off keeps the entry so the next tick tries again."""
scheduler._keep_warm[99] = _KeepWarmEntry(started=NOW - 60, held_target=BED_TARGET)
client = _make_client()
client.set_bed_temperature.side_effect = RuntimeError("mqtt down")
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, items=[], busy_printers=set())
client.set_bed_temperature.assert_called_once_with(0)
assert 99 in scheduler._keep_warm, "a failed release must not silently drop the entry"
def test_sample_chamber_temps_evicts_preheat_pin_for_removed_printer(scheduler):
"""Per-printer preheat state is evicted with the rest when a printer disappears."""
scheduler._preheat_pin[99] = {"bed"}
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
with (
patch("backend.app.services.print_scheduler.time") as t,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
):
t.monotonic.return_value = NOW
pm.get_all_statuses.return_value = {PRINTER_ID: SimpleNamespace(connected=True, temperatures={"chamber": 40.0})}
scheduler._sample_chamber_temps()
assert 99 not in scheduler._preheat_pin
assert PRINTER_ID in scheduler._preheat_pin
# ---------------------------------------------------------------------------
# Lazy filament target fetch
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_keep_warm_does_not_fetch_filament_targets_when_all_overrides(scheduler):
"""Per-item overrides supply chamber_needed → skip the DB round-trip."""
client = _make_client()
fetch_targets = AsyncMock(return_value={})
with (
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_get_preheat_filament_targets", fetch_targets),
):
pm.get_status.return_value = _make_state()
pm.get_client.return_value = client
# Item has an explicit chamber override, so derivation is not needed.
await _run(scheduler)
fetch_targets.assert_not_called()
@pytest.mark.asyncio
async def test_keep_warm_fetches_filament_targets_once_per_tick(scheduler):
"""When derivation is needed for multiple printers, only fetch targets once."""
items = [
_make_item(item_id=1, printer_id=1, preheat_chamber_target_override=None),
_make_item(item_id=2, printer_id=2, preheat_chamber_target_override=None),
]
client1 = _make_client()
client2 = _make_client()
fetch_targets = AsyncMock(return_value={"ASA": 60})
with (
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_get_preheat_filament_targets", fetch_targets),
patch.object(scheduler, "_get_printer", AsyncMock(return_value=SimpleNamespace(id=1, model="H2D"))),
patch.object(scheduler, "_derive_chamber_target", return_value=60),
):
pm.get_status.return_value = _make_state()
pm.get_client.side_effect = lambda pid: {1: client1, 2: client2}[pid]
await _run(scheduler, items=items, busy_printers={1, 2})
assert fetch_targets.call_count == 1
@pytest.mark.asyncio
async def test_keep_warm_timeout_honours_configured_minutes(scheduler):
"""A 15-minute limit stops the hold at 15 minutes, not at the default.
The whole point of `queue_keep_warm_max_minutes`: a user who does not want
a bed sitting hot while they are away sets a short window, and the heaters
go off when it elapses.
"""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (15 * 60 + 1),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, max_hold_minutes=15)
client.set_bed_temperature.assert_called_once_with(0)
assert scheduler._keep_warm[PRINTER_ID].expired is True
@pytest.mark.asyncio
async def test_keep_warm_holds_within_configured_window(scheduler):
"""Just inside the configured window the hold continues untouched."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(
started=NOW - (15 * 60 - 60),
held_target=BED_TARGET,
)
client = _make_client()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
# Firmware already holds the target, so an untouched hold means no publish.
pm.get_status.return_value = _make_state(bed_target=float(BED_TARGET))
pm.get_client.return_value = client
await _run(scheduler, max_hold_minutes=15)
client.set_bed_temperature.assert_not_called()
assert scheduler._keep_warm[PRINTER_ID].expired is False
# ---------------------------------------------------------------------------
# Handing the hold to the preheat pin, and getting it back when dispatch bails
# ---------------------------------------------------------------------------
#
# `_sweep_keep_warm` gives up the keep-warm entry for a printer being dispatched
# this tick and pins "bed" instead, on the promise that `_dispatch_one` unwinds
# it on any non-success exit. Two of `_dispatch_one`'s exits used to break that
# promise by returning before the `finally` could run, which left the bed hot
# with the entry already gone -- so neither the max-duration cap nor
# `_release_keep_warm` applied, and on the printer's last pending item nothing
# would ever switch it off.
def test_dispatch_handover_records_the_held_target(scheduler):
"""The pin remembers what keep-warm was holding, not just that it held."""
scheduler._keep_warm[PRINTER_ID] = _KeepWarmEntry(started=NOW, held_target=HOLD_TEMP)
scheduler._sweep_keep_warm(active_candidates=set(), dispatched={PRINTER_ID})
assert PRINTER_ID not in scheduler._keep_warm
assert scheduler._preheat_pin[PRINTER_ID] == {"bed"}
assert scheduler._preheat_pin_bed[PRINTER_ID] == HOLD_TEMP
@pytest.mark.asyncio
async def test_unclaimable_item_releases_the_handed_over_bed(scheduler):
"""A cancel landing between selection and the claim must not strand the bed.
`_claim_for_dispatch` returning False exits before the try/finally, so the
rollback has to fire on that path explicitly.
"""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with (
patch("backend.app.services.print_scheduler.async_session") as session_factory,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=False)),
):
session_factory.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
await scheduler._dispatch_one(42, selected_printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._preheat_pin
assert PRINTER_ID not in scheduler._preheat_pin_bed
@pytest.mark.asyncio
async def test_unclaimable_item_without_a_known_printer_is_a_noop(scheduler):
"""Direct callers that pass no printer keep the old behaviour."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
client = MagicMock()
with (
patch("backend.app.services.print_scheduler.async_session") as session_factory,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=False)),
):
session_factory.return_value.__aenter__ = AsyncMock(return_value=MagicMock())
session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
pm.get_client.return_value = client
await scheduler._dispatch_one(42)
client.set_bed_temperature.assert_not_called()
assert scheduler._preheat_pin[PRINTER_ID] == {"bed"}
@pytest.mark.asyncio
async def test_vanished_item_releases_the_handed_over_bed(scheduler):
"""The row disappearing after a successful claim takes the same exit.
That return is inside the try, but `item_printer_id` used to still be None
there, so the rollback was skipped by its own guard.
"""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
item_db = MagicMock()
item_db.get = AsyncMock(return_value=None)
with (
patch("backend.app.services.print_scheduler.async_session") as session_factory,
patch("backend.app.services.print_scheduler.printer_manager") as pm,
patch.object(scheduler, "_claim_for_dispatch", AsyncMock(return_value=True)),
patch.object(scheduler, "_clear_dispatch_claim", AsyncMock()),
patch.object(scheduler, "_release_unconfirmed_budget_reservation", AsyncMock()),
):
session_factory.return_value.__aenter__ = AsyncMock(return_value=item_db)
session_factory.return_value.__aexit__ = AsyncMock(return_value=False)
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
await scheduler._dispatch_one(42, selected_printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
assert PRINTER_ID not in scheduler._preheat_pin
# ---------------------------------------------------------------------------
# Rollback leaves a bed somebody else now owns alone
# ---------------------------------------------------------------------------
def test_rollback_leaves_a_reassigned_bed_alone(scheduler):
"""Firmware reports a target we did not set → the bed belongs to someone else."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": 45})
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_not_called()
assert PRINTER_ID not in scheduler._preheat_pin
assert PRINTER_ID not in scheduler._preheat_pin_bed
def test_rollback_switches_off_when_the_target_still_matches(scheduler):
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = SimpleNamespace(temperatures={"bed_target": HOLD_TEMP})
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
def test_rollback_switches_off_when_the_target_cannot_be_read(scheduler):
"""No evidence is not evidence of reassignment -- err towards a cold bed."""
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
client = MagicMock()
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_client.return_value = client
pm.get_status.return_value = None
scheduler._rollback_preheat_pin(item_id=42, printer_id=PRINTER_ID)
client.set_bed_temperature.assert_called_once_with(0)
def test_unregistered_printer_evicts_the_recorded_bed_target(scheduler):
scheduler._preheat_pin[PRINTER_ID] = {"bed"}
scheduler._preheat_pin_bed[PRINTER_ID] = HOLD_TEMP
with patch("backend.app.services.print_scheduler.printer_manager") as pm:
pm.get_all_statuses.return_value = {}
scheduler._sample_chamber_temps()
assert PRINTER_ID not in scheduler._preheat_pin
assert PRINTER_ID not in scheduler._preheat_pin_bed

View file

@ -295,21 +295,70 @@ async def test_malformed_filament_targets_falls_back_to_defaults(scheduler, item
@pytest.mark.asyncio
async def test_no_bed_temperature_in_archive_skips(scheduler, item):
"""Archive without bed_temperature metadata skips entirely rather than
guessing a default that might wreck a non-PLA print."""
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()),
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=["ABS"])
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()
@ -359,7 +408,10 @@ async def test_p1s_no_chamber_sensor_uses_soak_timer_only(scheduler, item, archi
client.set_bed_temperature.assert_called_once_with(60)
client.set_chamber_temperature.assert_not_called()
assert 600 in [call.args[0] for call in sleep_mock.call_args_list]
# 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

View file

@ -0,0 +1,693 @@
"""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)

View file

@ -1353,6 +1353,9 @@ export interface AppSettings {
preheat_filament_targets: string;
preheat_max_wait_seconds: number;
preheat_soak_seconds: number;
queue_keep_bed_warm: boolean;
queue_keep_warm_bed_temp: number;
queue_keep_warm_max_minutes: number;
// User-configurable presets for the printer-card popovers (JSON arrays of 3 ints).
// Empty string = use built-in defaults.
nozzle_temp_presets: string;

View file

@ -2291,6 +2291,12 @@ export default {
plateClear: 'Druckplatte-Bestätigung',
requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatten-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Wenn dies deaktiviert ist, werden auch das Druckplatten-Status-Badge und die Schaltfläche "Druckplatte als freigegeben markieren" auf den Druckerkarten ausgeblendet.',
keepBedWarm: 'Heizbett zwischen Drucken warmhalten',
keepBedWarmDesc: 'Während auf die Druckplatten-Bestätigung gewartet wird, wird das Heizbett auf der unten eingestellten Warmhalte-Temperatur gehalten, damit die Kammer warm bleibt — oder auf der Betttemperatur des nächsten Drucks, falls diese höher ist. Gilt nur, wenn der nächste Druck Kammerwärme benötigt (ASA, ABS, PA, PC usw.). Erfordert aktivierte Druckplatten-Bestätigung.',
keepWarmBedTemp: 'Warmhalte-Temperatur des Heizbetts (°C)',
keepWarmBedTempHelp: 'Wird verwendet, wenn das Bett die Kammer heizen soll — beim Warmhalten und beim Vorheizen, wenn die Druckdatei keine Betttemperatur enthält. Eine höhere Betttemperatur aus der Druckdatei hat immer Vorrang.',
keepWarmMaxMinutes: 'Warmhalten beenden nach (Minuten)',
keepWarmMaxMinutesHelp: 'Wird die Druckplatte nicht innerhalb dieser Zeit geleert, werden die Heizungen abgeschaltet statt dauerhaft zu heizen.',
gcodeInjection: 'G-Code-Injektion',
gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn "G-code einfügen" bei einem Warteschlangen-Element aktiviert ist.',
gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',

View file

@ -2310,6 +2310,12 @@ export default {
plateClear: 'Plate-Clear Confirmation',
requirePlateClear: 'Require plate-clear confirmation',
requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disabling this also hides the plate status badge and the "Mark plate as cleared" button on printer cards.',
keepBedWarm: 'Keep bed warm between prints',
keepBedWarmDesc: 'While awaiting plate-clear, hold the bed at the keep-warm temperature below so the chamber stays hot — or at the next print\'s own bed temperature when that is higher. Only applies when the next print needs chamber heating (ASA, ABS, PA, PC etc.). Requires plate-clear confirmation to be enabled.',
keepWarmBedTemp: 'Keep-warm bed temperature (°C)',
keepWarmBedTempHelp: 'Used whenever the bed\'s job is to heat the chamber — the keep-warm hold, and preheat when the print file carries no bed temperature. A higher bed temperature from the print file always wins.',
keepWarmMaxMinutes: 'Stop keeping warm after (minutes)',
keepWarmMaxMinutesHelp: 'If the plate is not cleared within this time, the heaters are switched off rather than held indefinitely.',
gcodeInjection: 'G-code Injection',
gcodeInjectionDescription: 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.',
gcodeInjectionNoPrinters: 'No printers found. Add printers to configure G-code snippets.',

View file

@ -2294,6 +2294,12 @@ export default {
plateClear: 'Confirmación de cama despejada',
requirePlateClear: 'Requerir confirmación de cama despejada',
requirePlateClearDescription: 'Cuando está activado, el planificador espera la confirmación de cama despejada por impresora antes de iniciar impresiones en cola en impresoras con trabajos finalizados. Desactivar esto también oculta la insignia de estado de la cama y el botón "Marcar cama como despejada" en las tarjetas de impresora.',
keepBedWarm: 'Mantener la cama caliente entre impresiones',
keepBedWarmDesc: 'Mientras se espera la confirmación de limpieza de placa, mantiene la cama a la temperatura de mantenimiento indicada abajo para que la cámara siga caliente — o a la temperatura de cama de la siguiente impresión si es mayor. Solo aplica cuando la siguiente impresión requiere calefacción de cámara (ASA, ABS, PA, PC, etc.). Requiere la confirmación de limpieza de placa activada.',
keepWarmBedTemp: 'Temperatura de la cama en mantenimiento (°C)',
keepWarmBedTempHelp: 'Se usa siempre que la cama sirve para calentar la cámara: durante el mantenimiento en caliente y en el precalentado cuando el archivo no incluye temperatura de cama. Una temperatura mayor del archivo siempre tiene prioridad.',
keepWarmMaxMinutes: 'Dejar de mantener caliente tras (minutos)',
keepWarmMaxMinutesHelp: 'Si la placa no se limpia dentro de este tiempo, los calentadores se apagan en lugar de mantenerse encendidos indefinidamente.',
gcodeInjection: 'Inyección de G-code',
gcodeInjectionDescription: 'Configure G-code personalizado para inyectar al inicio o al final de las impresiones para sistemas de impresión automática como Farmloop, SwapMod, AutoClear y Printflow 3D. Los fragmentos se configuran por modelo de impresora y se aplican cuando se activa "Inyectar G-code" en un elemento de la cola.',
gcodeInjectionNoPrinters: 'No se encontraron impresoras. Añada impresoras para configurar fragmentos de G-code.',

View file

@ -2247,6 +2247,12 @@ export default {
plateClear: 'Confirmation de plateau libre',
requirePlateClear: 'Exiger la confirmation de plateau libre',
requirePlateClearDescription: 'Lorsque cette option est activée, le planificateur attend une confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. La désactiver masque également le badge d\'état du plateau et le bouton « Marquer le plateau comme dégagé » sur les cartes d\'imprimante.',
keepBedWarm: 'Maintenir le plateau chaud entre les impressions',
keepBedWarmDesc: 'En attendant la confirmation de plateau libéré, maintient le plateau à la température de maintien au chaud ci-dessous pour que la chambre reste chaude — ou à la température de plateau de l\'impression suivante si elle est plus élevée. S\'applique uniquement lorsque l\'impression suivante nécessite un chauffage de chambre (ASA, ABS, PA, PC, etc.). Nécessite l\'activation de la confirmation de plateau libéré.',
keepWarmBedTemp: 'Température du plateau en maintien au chaud (°C)',
keepWarmBedTempHelp: 'Utilisé lorsque le plateau sert à chauffer la chambre : pendant le maintien au chaud et lors du préchauffage si le fichier d\'impression ne contient pas de température de plateau. Une température plus élevée issue du fichier est toujours prioritaire.',
keepWarmMaxMinutes: 'Arrêter le maintien au chaud après (minutes)',
keepWarmMaxMinutesHelp: 'Si le plateau n\'est pas libéré dans ce délai, les chauffages sont coupés au lieu d\'être maintenus indéfiniment.',
gcodeInjection: 'Injection de G-code',
gcodeInjectionDescription: 'Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d\'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d\'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d\'attente.',
gcodeInjectionNoPrinters: 'Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.',

View file

@ -2247,6 +2247,12 @@ export default {
plateClear: 'Conferma piatto libero',
requirePlateClear: 'Richiedi conferma piatto libero',
requirePlateClearDescription: 'Quando questa opzione è abilitata, lo scheduler attende una conferma per stampante che il piatto sia libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitandola vengono nascosti anche il badge di stato del piatto e il pulsante "Segna il piatto come liberato" sulle schede stampante.',
keepBedWarm: 'Mantieni il piano caldo tra le stampe',
keepBedWarmDesc: 'In attesa della conferma di piatto liberato, mantiene il piano alla temperatura di mantenimento indicata sotto così la camera resta calda — o alla temperatura del piano della stampa successiva se è più alta. Si applica solo quando la stampa successiva richiede il riscaldamento della camera (ASA, ABS, PA, PC, ecc.). Richiede la conferma di piatto liberato abilitata.',
keepWarmBedTemp: 'Temperatura del piano in mantenimento (°C)',
keepWarmBedTempHelp: 'Usato quando il piano serve a riscaldare la camera: durante il mantenimento in caldo e nel preriscaldamento se il file di stampa non contiene una temperatura del piano. Una temperatura più alta indicata dal file ha sempre la precedenza.',
keepWarmMaxMinutes: 'Interrompi il mantenimento in caldo dopo (minuti)',
keepWarmMaxMinutesHelp: 'Se il piatto non viene liberato entro questo tempo, i riscaldatori vengono spenti invece di restare accesi indefinitamente.',
gcodeInjection: 'Iniezione G-code',
gcodeInjectionDescription: 'Configura G-code personalizzato da iniettare all\'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando "Inietta G-code" è abilitato su un elemento della coda.',
gcodeInjectionNoPrinters: 'Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.',

View file

@ -2290,6 +2290,12 @@ export default {
plateClear: 'プレートクリア確認',
requirePlateClear: 'プレートクリア確認を必須にする',
requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。無効にすると、プリンターカード上のプレート状態バッジと「プレートをクリア済みにする」ボタンも非表示になります。',
keepBedWarm: '印刷間ベッド温度維持',
keepBedWarmDesc: 'プレートクリア確認待ち中、下記の保温温度でベッドを維持してチャンバーを温かく保ちます。次の印刷のベッド温度の方が高い場合はそちらを使用します。次の印刷がチャンバー加熱を必要とする場合のみ適用されますASA、ABS、PA、PC など)。プレートクリア確認が有効になっている必要があります。',
keepWarmBedTemp: '保温時のベッド温度 (°C)',
keepWarmBedTempHelp: 'ベッドでチャンバーを加熱する場面で使用されます(保温中、および印刷ファイルにベッド温度が含まれない場合の予熱時)。印刷ファイルのベッド温度の方が高い場合は、常にそちらが優先されます。',
keepWarmMaxMinutes: '保温を終了するまでの時間(分)',
keepWarmMaxMinutesHelp: 'この時間内にプレートが片付けられない場合、ヒーターは無期限に加熱を続けず停止します。',
gcodeInjection: 'G-codeインジェクション',
gcodeInjectionDescription: 'Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム<E38386><E383A0>「G-codeを挿入」を有効にすると適用されます。',
gcodeInjectionNoPrinters: 'プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。',

View file

@ -2171,6 +2171,12 @@ export default {
plateClear: '플레이트 비움 확인',
requirePlateClear: '플레이트 비움 확인 필요',
requirePlateClearDescription: '활성화하면 스케줄러가 완료된 작업이 있는 프린터에서 대기 중인 인쇄를 시작하기 전에 프린터별 플레이트 비움 확인을 기다립니다.',
keepBedWarm: '출력 사이에 베드 온도 유지',
keepBedWarmDesc: '플레이트 클리어 확인 대기 중에 아래의 온도 유지 값으로 베드를 유지하여 챔버를 따뜻하게 유지합니다. 다음 출력의 베드 온도가 더 높으면 그 값을 사용합니다. 다음 출력에 챔버 가열이 필요한 경우에만 적용됩니다(ASA, ABS, PA, PC 등). 플레이트 클리어 확인이 활성화되어 있어야 합니다.',
keepWarmBedTemp: '온도 유지 시 베드 온도 (°C)',
keepWarmBedTempHelp: '베드로 챔버를 가열해야 할 때 사용됩니다(온도 유지 중, 그리고 출력 파일에 베드 온도가 없는 경우의 예열 시). 출력 파일의 베드 온도가 더 높으면 항상 그 값이 우선합니다.',
keepWarmMaxMinutes: '온도 유지 종료 시간(분)',
keepWarmMaxMinutesHelp: '이 시간 안에 플레이트를 비우지 않으면 히터를 무기한 켜두지 않고 끕니다.',
gcodeInjection: 'G코드 주입',
gcodeInjectionDescription: '자동 인쇄 시스템을 위한 인쇄 시작 및/또는 종료 시 주입할 사용자 지정 G코드를 설정하세요.',
gcodeInjectionNoPrinters: '프린터가 없습니다. G코드 스니펫을 설정하려면 프린터를 추가하세요.',

View file

@ -2247,6 +2247,12 @@ export default {
plateClear: 'Confirmação de placa livre',
requirePlateClear: 'Exigir confirmação de placa livre',
requirePlateClearDescription: 'Quando ativado, o agendador aguarda uma confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desativar isso também oculta o indicador de status da placa e o botão "Marcar placa como liberada" nos cartões das impressoras.',
keepBedWarm: 'Manter a mesa aquecida entre impressões',
keepBedWarmDesc: 'Enquanto aguarda a confirmação de placa limpa, mantém a mesa na temperatura de aquecimento definida abaixo para que a câmara continue quente — ou na temperatura da mesa da próxima impressão, se for maior. Aplica-se apenas quando a próxima impressão requer aquecimento de câmara (ASA, ABS, PA, PC, etc.). Requer a confirmação de placa limpa ativada.',
keepWarmBedTemp: 'Temperatura da mesa em aquecimento (°C)',
keepWarmBedTempHelp: 'Usado sempre que a mesa serve para aquecer a câmara: durante a manutenção aquecida e no pré-aquecimento quando o arquivo de impressão não traz temperatura da mesa. Uma temperatura maior vinda do arquivo sempre prevalece.',
keepWarmMaxMinutes: 'Parar de manter aquecido após (minutos)',
keepWarmMaxMinutesHelp: 'Se a placa não for liberada dentro desse tempo, os aquecedores são desligados em vez de ficarem ligados indefinidamente.',
gcodeInjection: 'Injeção de G-code',
gcodeInjectionDescription: 'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando "Injetar G-code" está ativado em um item da fila.',
gcodeInjectionNoPrinters: 'Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.',

View file

@ -2171,6 +2171,12 @@ export default {
plateClear: "Подтверждение очистки пластины",
requirePlateClear: "Требовать подтверждение очистки пластины",
requirePlateClearDescription: "Если включено, планировщик не запускает следующее задание на принтере с завершённой печатью, пока не подтверждена очистка пластины. Отключение также скрывает индикатор состояния пластины и кнопку «Пластина очищена» на карточках принтеров.",
keepBedWarm: 'Держать стол тёплым между печатями',
keepBedWarmDesc: 'Пока ожидается подтверждение очистки пластины, стол поддерживается при указанной ниже температуре подогрева, чтобы камера оставалась тёплой, — или при температуре стола следующей печати, если она выше. Применяется только если следующая печать требует нагрева камеры (ASA, ABS, PA, PC и др.). Требуется включённое подтверждение очистки пластины.',
keepWarmBedTemp: 'Температура стола при поддержании тепла (°C)',
keepWarmBedTempHelp: 'Используется, когда стол служит для нагрева камеры: при поддержании тепла и при предварительном нагреве, если в файле печати нет температуры стола. Более высокая температура из файла всегда имеет приоритет.',
keepWarmMaxMinutes: 'Прекратить подогрев через (минут)',
keepWarmMaxMinutesHelp: 'Если пластина не освобождена за это время, нагреватели выключаются, а не остаются включёнными бесконечно.',
gcodeInjection: "Вставка G-кода",
gcodeInjectionDescription: "Настройте G-код, добавляемый в начало и/или конец печати для систем автоматического запуска, таких как Farmloop, SwapMod, AutoClear и Printflow 3D. Фрагменты задаются отдельно для каждой модели принтера и применяются, когда для задания очереди включена вставка G-кода.",
gcodeInjectionNoPrinters: "Принтеры не найдены. Добавьте принтеры, чтобы настроить фрагменты G-кода.",

View file

@ -2295,6 +2295,12 @@ export default {
plateClear: 'Plaka Temizleme Onayı',
requirePlateClear: 'Plaka temizleme onayı gerektir',
requirePlateClearDescription: 'Etkinleştirildiğinde, planlayıcı bitmiş işleri olan yazıcılarda kuyruktaki baskıları başlatmadan önce yazıcı başına plaka temizleme onayını bekler. Bunu devre dışı bırakmak ayrıca plaka durum rozetini ve yazıcı kartlarındaki "Plakayı temizlendi olarak işaretle" düğmesini gizler.',
keepBedWarm: 'Baskılar arasında yatak sıcaklığını koru',
keepBedWarmDesc: 'Plaka temizleme onayı beklenirken, yatağı aşağıdaki sıcak tutma sıcaklığında tutarak hazneyi sıcak tutar — veya bir sonraki baskının yatak sıcaklığı daha yüksekse onu kullanır. Yalnızca bir sonraki baskının hazne ısıtması gerektirmesi durumunda uygulanır (ASA, ABS, PA, PC vb.). Plaka temizleme onayının etkinleştirilmiş olması gerekir.',
keepWarmBedTemp: 'Sıcak tutma yatak sıcaklığı (°C)',
keepWarmBedTempHelp: 'Yatağın görevi hazneyi ısıtmak olduğunda kullanılır: sıcak tutma sırasında ve baskı dosyasında yatak sıcaklığı bulunmadığında ön ısıtmada. Baskı dosyasındaki daha yüksek bir sıcaklık her zaman önceliklidir.',
keepWarmMaxMinutes: 'Sıcak tutmayı şu süre sonunda durdur (dakika)',
keepWarmMaxMinutesHelp: 'Plaka bu süre içinde boşaltılmazsa ısıtıcılar süresiz açık kalmak yerine kapatılır.',
gcodeInjection: 'G-kod Enjeksiyonu',
gcodeInjectionDescription: 'Farmloop, SwapMod, AutoClear ve Printflow 3D gibi otomatik baskı sistemleri için baskıların başlangıcında ve/veya sonunda enjekte edilecek özel G-kodu yapılandırın. Parçacıklar yazıcı modeli başına yapılandırılır ve bir kuyruk öğesinde "G-kod Enjekte Et" etkinleştirildiğinde uygulanır.',
gcodeInjectionNoPrinters: 'Yazıcı bulunamadı. G-kod parçacıklarını yapılandırmak için yazıcı ekleyin.',

View file

@ -2310,6 +2310,12 @@ export default {
plateClear: "Підтвердження очищення друкарської пластини",
requirePlateClear: "Вимагати підтвердження очищення друкарської пластини",
requirePlateClearDescription: "Якщо ввімкнено, планувальник перед запуском наступного завдання на принтері із завершеним друком очікує підтвердження, що друкарську пластину очищено. Вимкнення також приховує індикатор стану пластини та кнопку «Позначити пластину як очищену» на картках принтерів.",
keepBedWarm: "Тримати стіл теплим між друком",
keepBedWarmDesc: "Поки очікується підтвердження очищення пластини, стіл підтримується при вказаній нижче температурі підігріву, щоб камера залишалася теплою, — або при температурі столу наступного друку, якщо вона вища. Застосовується лише коли наступний друк потребує нагрівання камери (ASA, ABS, PA, PC тощо). Потрібно увімкнути підтвердження очищення пластини.",
keepWarmBedTemp: "Температура столу для підтримання тепла (°C)",
keepWarmBedTempHelp: "Використовується, коли стіл слугує для нагрівання камери: під час підтримання тепла та під час попереднього нагріву, якщо у файлі друку немає температури столу. Вища температура з файлу друку завжди має пріоритет.",
keepWarmMaxMinutes: "Припинити підігрів через (хвилин)",
keepWarmMaxMinutesHelp: "Якщо пластину не звільнено за цей час, нагрівачі вимикаються, а не залишаються увімкненими нескінченно.",
gcodeInjection: "Вставлення G-коду",
gcodeInjectionDescription: "Налаштуйте власний G-код для вставлення на початку та/або наприкінці друку в системах автоматичного друку, як-от Farmloop, SwapMod, AutoClear і Printflow 3D. Фрагменти налаштовуються для кожної моделі принтера та застосовуються, коли для елемента черги ввімкнено «Вставляти G-код».",
gcodeInjectionNoPrinters: "Принтерів не знайдено. Додайте принтери, щоб налаштувати фрагменти G-коду.",

View file

@ -2292,6 +2292,12 @@ export default {
plateClear: '热床清空确认',
requirePlateClear: '需要热床清空确认',
requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。禁用后,也会隐藏打印机卡片上的打印板状态标记和“将打印板标记为已清理”按钮。',
keepBedWarm: '在打印之间保持热床温度',
keepBedWarmDesc: '在等待打印板清空确认时将热床保持在下方设置的保温温度使腔室保持温热若下一个任务的热床温度更高则使用该温度。仅在下一次打印需要腔室加热ASA、ABS、PA、PC 等)时适用。需要启用打印板清空确认。',
keepWarmBedTemp: '保温热床温度 (°C)',
keepWarmBedTempHelp: '当热床用于加热腔室时使用:包括保温期间,以及打印文件未包含热床温度时的预热。打印文件中更高的热床温度始终优先。',
keepWarmMaxMinutes: '停止保温的时间(分钟)',
keepWarmMaxMinutesHelp: '若在此时间内未清空打印板,加热器将关闭,而不是无限期保持加热。',
gcodeInjection: 'G-code注入',
gcodeInjectionDescription: '为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code在打印开始和/或结束时注入。代码片段按打印机型号配置,在队列项目上启用"注入G-code"时应用。',
gcodeInjectionNoPrinters: '未找到打印机。添加打印机以配置G-code代码片段。',

View file

@ -2292,6 +2292,12 @@ export default {
plateClear: '熱床清空確認',
requirePlateClear: '需要熱床清空確認',
requirePlateClearDescription: '啟用後,排程器會在已完成列印的印表機上啟動佇列列印之前,等待每臺印表機的熱床清空確認。停用後,也會隱藏印表機卡片上的列印板狀態標記和「將列印板標記為已清理」按鈕。',
keepBedWarm: '在列印之間保持熱床溫度',
keepBedWarmDesc: '在等待列印板清空確認時將熱床維持在下方設定的保溫溫度使腔室保持溫熱若下一個任務的熱床溫度更高則使用該溫度。僅在下一次列印需要腔室加熱ASA、ABS、PA、PC 等)時適用。需要啟用列印板清空確認。',
keepWarmBedTemp: '保溫熱床溫度 (°C)',
keepWarmBedTempHelp: '當熱床用於加熱腔室時使用:包括保溫期間,以及列印檔案未包含熱床溫度時的預熱。列印檔案中較高的熱床溫度一律優先。',
keepWarmMaxMinutes: '停止保溫的時間(分鐘)',
keepWarmMaxMinutesHelp: '若在此時間內未清空列印板,加熱器將關閉,而不是無限期保持加熱。',
gcodeInjection: 'G-code注入',
gcodeInjectionDescription: '為Farmloop、SwapMod、AutoClear和Printflow 3D等自動列印系統設定自訂G-code在列印開始和/或結束時注入。程式碼片段按印表機型號設定,在佇列項目上啟用"注入G-code"時套用。',
gcodeInjectionNoPrinters: '未找到印表機。新增印表機以設定G-code程式碼片段。',

View file

@ -1091,6 +1091,9 @@ export function SettingsPage() {
(baseline.preheat_filament_targets ?? '') !== (localSettings.preheat_filament_targets ?? '') ||
(baseline.preheat_max_wait_seconds ?? 900) !== (localSettings.preheat_max_wait_seconds ?? 900) ||
(baseline.preheat_soak_seconds ?? 300) !== (localSettings.preheat_soak_seconds ?? 300) ||
(baseline.queue_keep_bed_warm ?? false) !== (localSettings.queue_keep_bed_warm ?? false) ||
(baseline.queue_keep_warm_bed_temp ?? 90) !== (localSettings.queue_keep_warm_bed_temp ?? 90) ||
(baseline.queue_keep_warm_max_minutes ?? 120) !== (localSettings.queue_keep_warm_max_minutes ?? 120) ||
(baseline.nozzle_temp_presets ?? '') !== (localSettings.nozzle_temp_presets ?? '') ||
(baseline.bed_temp_presets ?? '') !== (localSettings.bed_temp_presets ?? '') ||
(baseline.chamber_temp_presets ?? '') !== (localSettings.chamber_temp_presets ?? '') ||
@ -1200,6 +1203,9 @@ export function SettingsPage() {
preheat_filament_targets: localSettings.preheat_filament_targets,
preheat_max_wait_seconds: localSettings.preheat_max_wait_seconds,
preheat_soak_seconds: localSettings.preheat_soak_seconds,
queue_keep_bed_warm: localSettings.queue_keep_bed_warm,
queue_keep_warm_bed_temp: localSettings.queue_keep_warm_bed_temp,
queue_keep_warm_max_minutes: localSettings.queue_keep_warm_max_minutes,
nozzle_temp_presets: localSettings.nozzle_temp_presets,
bed_temp_presets: localSettings.bed_temp_presets,
chamber_temp_presets: localSettings.chamber_temp_presets,
@ -4930,6 +4936,64 @@ export function SettingsPage() {
</p>
</div>
</div>
{/* Keep bed warm between consecutive queue prints */}
<div className="flex items-center justify-between pt-2 border-t border-bambu-dark-tertiary/50">
<div className="flex-1 mr-4">
<p className="text-sm text-white">
{t('settings.keepBedWarm', 'Keep bed warm between prints')}
</p>
<p className="text-xs text-bambu-gray mt-0.5">
{t('settings.keepBedWarmDesc', 'While awaiting plate-clear, hold the bed at the keep-warm temperature below so the chamber stays hot — or at the next print\'s own bed temperature when that is higher. Only applies when the next print needs chamber heating (ASA, ABS, PA, PC etc.). Requires plate-clear confirmation to be enabled.')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={localSettings.queue_keep_bed_warm ?? false}
onChange={(e) => updateSetting('queue_keep_bed_warm', e.target.checked)}
className="sr-only peer"
disabled={!(localSettings.preheat_enabled ?? false) || !(localSettings.require_plate_clear ?? false)}
/>
<div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green peer-disabled:opacity-50 peer-disabled:cursor-not-allowed"></div>
</label>
</div>
<div className="grid grid-cols-2 gap-3 mt-2">
<div>
<label className="block text-xs text-bambu-gray mb-1">
{t('settings.keepWarmBedTemp', 'Keep-warm bed temperature (°C)')}
</label>
<input
type="number"
min={40}
max={110}
value={localSettings.queue_keep_warm_bed_temp ?? 90}
onChange={(e) => updateSetting('queue_keep_warm_bed_temp', Math.max(40, Math.min(110, parseInt(e.target.value) || 90)))}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
disabled={!(localSettings.preheat_enabled ?? false)}
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.keepWarmBedTempHelp', 'Used whenever the bed\'s job is to heat the chamber — the keep-warm hold, and preheat when the print file carries no bed temperature. A higher bed temperature from the print file always wins.')}
</p>
</div>
<div>
<label className="block text-xs text-bambu-gray mb-1">
{t('settings.keepWarmMaxMinutes', 'Stop keeping warm after (minutes)')}
</label>
<input
type="number"
min={5}
max={480}
value={localSettings.queue_keep_warm_max_minutes ?? 120}
onChange={(e) => updateSetting('queue_keep_warm_max_minutes', Math.max(5, Math.min(480, parseInt(e.target.value) || 120)))}
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green disabled:opacity-50"
disabled={!(localSettings.queue_keep_bed_warm ?? false) || !(localSettings.preheat_enabled ?? false) || !(localSettings.require_plate_clear ?? false)}
/>
<p className="text-xs text-bambu-gray mt-1">
{t('settings.keepWarmMaxMinutesHelp', 'If the plate is not cleared within this time, the heaters are switched off rather than held indefinitely.')}
</p>
</div>
</div>
{/* Per-filament chamber target editor (#1468) */}
<div className="pt-2 border-t border-bambu-dark-tertiary/50">
<div className="flex items-center justify-between mb-1">

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -26,8 +26,8 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-CK67RtNz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-jOkuIvep.css">
<script type="module" crossorigin src="/assets/index-Bmu-wyBZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BkuH4t27.css">
</head>
<body>
<div id="root"></div>