mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
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:
commit
f982da2a49
26 changed files with 3177 additions and 37 deletions
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 3–30 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
|
||||
|
|
|
|||
514
backend/tests/unit/test_scheduler_chamber_soak.py
Normal file
514
backend/tests/unit/test_scheduler_chamber_soak.py
Normal 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 3–30 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
|
||||
899
backend/tests/unit/test_scheduler_keep_warm.py
Normal file
899
backend/tests/unit/test_scheduler_keep_warm.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
693
backend/tests/unit/test_scheduler_preheat_fast_path.py
Normal file
693
backend/tests/unit/test_scheduler_preheat_fast_path.py
Normal 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)
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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スニペットを設定するにはプリンターを追加してください。',
|
||||
|
|
|
|||
|
|
@ -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코드 스니펫을 설정하려면 프린터를 추가하세요.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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-кода.",
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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-коду.",
|
||||
|
|
|
|||
|
|
@ -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代码片段。',
|
||||
|
|
|
|||
|
|
@ -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程式碼片段。',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue