Files
bambuddy/backend/app/models/scheduled_drying.py
T
d37ce94f81 Feature: Scheduled drying (#2703)
* feat: add ScheduledDrying model for delayed drying runs (#2638)

* Release the printer when a scheduled dry ends (#2638)

_check_scheduled_dryings marks a printer as drying in _drying_in_progress,
which is shared with auto-drying. Auto-drying prunes that map in
_sync_drying_state(), but that call sits behind its enabled check, and this is
the first writer that runs whether auto-drying is on or not. With it off --
the default -- nothing dropped the entry short of a print being dispatched to
the same printer, so the next scheduled run parked on "already_drying"
forever and queue_drying_block held that printer's prints too. A nightly
off-peak dry with no printing in between is exactly the workflow this feature
is for: night one worked, every night after it silently did not. The check now
releases what it acquired, covering both a run that ends mid-pass and one
cancelled through the route between passes.

The retention prune ran on every pass. Issuing the DELETE is what opens a
write transaction, this method is called every 3s while the queue dispatches,
and rows only become prunable a week after they finish, so it is now gated to
hourly on a monotonic stamp -- with the first pass after a restart still
reaping whatever the dead process left behind.

Both drying paths now pick the blocking dry_sf_reason through one rule.
The immediate endpoint quoted whichever code the firmware listed first while
the scheduler prioritised power over retract, so one blocked AMS read two ways
depending on which button you pressed. drying_preflight.primary_reason_code
holds the order and both call it, including the flame button's tooltip, which
had no wording for filament at the outlet at all and sent those users to the
generic "can't start drying right now".

scheduled_drying joins the model list in init_db. The table was already
created -- importing the package registers it -- but it was the only model
relying on that indirection.

Tests: the release (completion and route-cancel), the prune throttle, the
shared reason rule, the tooltip priority, and four driving the real check_queue,
which nothing covered before -- a pass with no rows still dispatching prints, a
due row dispatching, and a failed row not stalling the queue behind it. Each
one fails against the code it guards.

---------

Co-authored-by: MartinNYHC <martin@bambuddy.cool>
Co-authored-by: maziggy <mz@v8w.de>
2026-08-15 13:40:21 +02:00

48 lines
2.0 KiB
Python

from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from backend.app.core.database import Base
class ScheduledDrying(Base):
"""A manual AMS drying run scheduled to start later (#2638).
Dispatched by PrintScheduler._check_scheduled_dryings() when start_after
has passed and the printer is idle. Parameters mirror the immediate
POST /printers/{id}/drying/start endpoint.
"""
__tablename__ = "scheduled_dryings"
id: Mapped[int] = mapped_column(primary_key=True)
printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
ams_id: Mapped[int] = mapped_column(Integer, default=0)
temp: Mapped[int] = mapped_column(Integer)
duration_hours: Mapped[int] = mapped_column(Integer)
filament: Mapped[str] = mapped_column(String(50), default="")
rotate_tray: Mapped[bool] = mapped_column(Boolean, default=False)
# Earliest start instant, naive UTC (same convention as
# print_queue.scheduled_time). None = start as soon as the printer is idle.
start_after: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# pending / running / completed / cancelled / failed
status: Mapped[str] = mapped_column(String(20), default="pending")
waiting_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
printer: Mapped["Printer"] = relationship()
created_by: Mapped["User | None"] = relationship()
from backend.app.models.printer import Printer # noqa: E402
from backend.app.models.user import User # noqa: E402