mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-19 07:33:18 +02:00
fix(finish-photo): drive capture from stg_cur=22, drop dispatch force-on (#1721)
capture_finish_photo (default-on) was forcing the timelapse MQTT field to true on every print, even when the user explicitly unchecked Timelapse in the slicer send dialog. On profiles with Timelapse Type = Smooth, that flipped the printer's timelapse_record_flag and un-gated the per-layer M622 J1 wipe blocks the slicer had baked in — toolhead parked off the part every layer, on prints the user opted out of recording. Root cause: #1397 implemented the finish-photo feature as a side channel of "force the printer into timelapse-recording mode at dispatch" so the last-frame extractor had a video to pull from. That conflated recording a timelapse with snapping a finish photo, and the per-layer side effects were decided at slice time by the user's timelapse_type, which Bambuddy has no visibility into post-slice. Fix: replace the force-on with a clean MQTT-state-driven trigger. bambu_mqtt.py fires a new on_finish_photo_moment callback when stg_cur transitions INTO 22 ("Filament unloading") while _was_running AND end-of-print gate matches (progress >= 99 OR layer_num >= total_layers OR remaining_time <= 0). The gate disambiguates from mid-print color swaps (which also transit stage 22 but at progress < 99). FINISH-state fallback in the same handler fires the callback at the existing transition if stage 22 never arrived (cancel, external-spool-only, HMS halt, firmware variants). main.py registers on_finish_photo_moment as a top-level handler. It pre-captures one camera frame at the trigger edge (external cam → buffered RTSP → fresh RTSP via capture_camera_frame_bytes) and caches the JPEG bytes in _stage22_finish_frames[printer_id]. _background_finish_photo consumes the cached bytes before its existing live-grab chain, so the saved photo has the better framing (toolhead parked, before bed drop) without restructuring the archive-resolution / fallback / notification wiring. When a timelapse IS actively recording (user explicitly opted in), pre-capture is skipped — _capture_finish_photo_from_timelapse still extracts the last frame, which is still the best framing and now has no force-on side effects because the user wanted the video. Removed: resolve_effective_timelapse, _resolve_effective_timelapse wrapper, both background_dispatch call sites, the print_scheduler call site, the archive.bambuddy_forced_timelapse write, _cleanup_forced_timelapse (~75 lines including the FTP-DELE walk across /timelapse, /timelapse/video, /record, /recording) and its call site. All paths now read bool(item.timelapse) / bool(job.options.get("timelapse", False)) directly. The archive.bambuddy_forced_timelapse DB column stays defined (default False) for back-compat with existing rows — no consumer reads it anymore.
This commit is contained in:
File diff suppressed because one or more lines are too long
+152
-106
@@ -333,6 +333,16 @@ logging.info("Bambuddy starting - debug=%s, log_level=%s", app_settings.debug, l
|
||||
# Track active prints: {(printer_id, filename): archive_id}
|
||||
_active_prints: dict[tuple[int, str], int] = {}
|
||||
|
||||
# #1721: stage-22 pre-captured finish photo bytes per printer. on_finish_photo_moment
|
||||
# fires when stg_cur enters 22 ("Filament unloading") at end-of-print — toolhead
|
||||
# parked, bed not yet dropped — and grabs a single camera frame into this cache.
|
||||
# `_background_finish_photo` (inside on_print_complete) consumes the cached bytes
|
||||
# instead of running its own grab-now chain when present, so the finish photo
|
||||
# captures the better-framed pre-bed-drop moment without us having to force
|
||||
# timelapse on at dispatch (the #1397 mechanism that caused #1721's per-layer
|
||||
# nozzle parking on slicer profiles with Timelapse Type = Smooth).
|
||||
_stage22_finish_frames: dict[int, bytes] = {}
|
||||
|
||||
# Per-printer "connected" edge tracker. Used by `on_printer_status_change`
|
||||
# to fire `reconcile_stale_active_prints` exactly once per (re)connection
|
||||
# (#1542 follow-up — power-cycle ghost prints). The value is True after
|
||||
@@ -1946,6 +1956,10 @@ async def on_print_start(printer_id: int, data: dict):
|
||||
# Clear any stale user-stopped flag from previous print cycles
|
||||
_user_stopped_printers.discard(printer_id)
|
||||
|
||||
# #1721: drop any leftover pre-captured finish frame from a prior print
|
||||
# so a never-consumed cache entry can't bleed into the new print's photo.
|
||||
_stage22_finish_frames.pop(printer_id, None)
|
||||
|
||||
# Cancel any active bed cooldown waiter for this printer
|
||||
if _bed_cool_waiters.pop(printer_id, None):
|
||||
logger.info("[BED-COOL] Cancelled bed cooldown waiter for printer %s (new print started)", printer_id)
|
||||
@@ -3321,101 +3335,6 @@ async def _capture_finish_photo_from_timelapse(
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
|
||||
async def _cleanup_forced_timelapse(archive_id: int, printer_id: int) -> None:
|
||||
"""Delete the timelapse Bambuddy forced on for #1397's finish-photo path.
|
||||
|
||||
Called from the finish-photo background task after the extractor has had
|
||||
its turn (regardless of whether extraction succeeded — the user never
|
||||
asked for a video and we shouldn't leave one behind even if ffmpeg
|
||||
failed). Cleanup is best-effort and never raises: a printer that's
|
||||
offline at cleanup time means a single orphaned file on the SD card,
|
||||
not a broken Bambuddy flow.
|
||||
|
||||
Cleans both:
|
||||
- the locally-attached file (clears archive.timelapse_path)
|
||||
- the printer-side file via FTP DELE
|
||||
"""
|
||||
from backend.app.models.archive import PrintArchive
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.bambu_ftp import delete_file_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
local_relpath: str | None = None
|
||||
printer = None
|
||||
|
||||
async with async_session() as db:
|
||||
archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
|
||||
archive = archive_result.scalar_one_or_none()
|
||||
if not archive or not archive.bambuddy_forced_timelapse:
|
||||
return
|
||||
|
||||
local_relpath = archive.timelapse_path
|
||||
if local_relpath:
|
||||
local_abspath = app_settings.base_dir / local_relpath
|
||||
try:
|
||||
if local_abspath.exists():
|
||||
local_abspath.unlink()
|
||||
logger.info(
|
||||
"[FORCED-TIMELAPSE] Deleted local timelapse %s for archive %s",
|
||||
local_relpath,
|
||||
archive_id,
|
||||
)
|
||||
except OSError as e:
|
||||
logger.warning("[FORCED-TIMELAPSE] Could not delete local timelapse %s: %s", local_relpath, e)
|
||||
archive.timelapse_path = None
|
||||
await db.commit()
|
||||
|
||||
printer_result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = printer_result.scalar_one_or_none()
|
||||
|
||||
if printer is None or not local_relpath:
|
||||
return
|
||||
|
||||
# _scan_for_timelapse_with_retries used the original filename when it
|
||||
# attached, so the basename of timelapse_path matches the printer-side
|
||||
# filename. Try the directories the scanner walks (#1397).
|
||||
from backend.app.services.bambu_ftp import DeleteResult
|
||||
|
||||
filename = Path(local_relpath).name
|
||||
any_real_failure = False
|
||||
for remote_dir in ("/timelapse", "/timelapse/video", "/record", "/recording"):
|
||||
remote_path = f"{remote_dir}/{filename}"
|
||||
try:
|
||||
result = await delete_file_async(
|
||||
printer.ip_address,
|
||||
printer.access_code,
|
||||
remote_path,
|
||||
printer_model=printer.model,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("[FORCED-TIMELAPSE] FTP delete attempt failed for %s: %s", remote_path, e)
|
||||
continue
|
||||
if result == DeleteResult.DELETED:
|
||||
logger.info("[FORCED-TIMELAPSE] Deleted printer-side timelapse %s", remote_path)
|
||||
return
|
||||
if result == DeleteResult.FAILED:
|
||||
any_real_failure = True
|
||||
|
||||
# All four dirs returned NOT_FOUND with no actual failures: the printer
|
||||
# never wrote a file under any expected path (or already swept). That's
|
||||
# the normal post-print state on most models — debug, not warning.
|
||||
if any_real_failure:
|
||||
logger.warning(
|
||||
"[FORCED-TIMELAPSE] Could not delete printer-side timelapse %s for archive %s "
|
||||
"(network/auth/transient error)",
|
||||
filename,
|
||||
archive_id,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"[FORCED-TIMELAPSE] No printer-side timelapse to delete for %s (archive %s) — "
|
||||
"every candidate dir returned 550",
|
||||
filename,
|
||||
archive_id,
|
||||
)
|
||||
|
||||
|
||||
async def on_print_running_observed(printer_id: int, data: dict):
|
||||
"""Restart-recovery: capture a fresh timelapse baseline for a print that
|
||||
started before Bambuddy came up.
|
||||
@@ -3609,6 +3528,117 @@ async def reconcile_stale_active_prints(printer_id: int) -> int:
|
||||
return reconciled
|
||||
|
||||
|
||||
async def on_finish_photo_moment(printer_id: int, data: dict):
|
||||
"""Pre-capture a finish photo when the printer enters stage 22 / FINISH (#1721).
|
||||
|
||||
Fires either at the stage-22 ("Filament unloading") edge — toolhead
|
||||
parked, bed not yet dropped, optimal framing — or as a FINISH-state
|
||||
fallback for prints that skip stage 22 (cancel, external-spool-only,
|
||||
HMS halt, firmware variants). Grabs one frame via the same
|
||||
external-camera / RTSP path the post-completion fallback uses, stores
|
||||
the JPEG bytes in ``_stage22_finish_frames[printer_id]``, and lets
|
||||
``_background_finish_photo`` consume the cached bytes when it runs.
|
||||
|
||||
Replaces the #1397 "force timelapse on at dispatch" mechanism, which
|
||||
caused per-layer nozzle parking on slicer profiles with Timelapse Type
|
||||
set to Smooth (#1721). No force-on now means the user's explicit
|
||||
timelapse=off in the slicer send dialog is respected.
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
trigger = data.get("trigger", "unknown")
|
||||
timelapse_was_active = bool(data.get("timelapse_was_active"))
|
||||
logger.info(
|
||||
"[FINISH-PHOTO-MOMENT] printer=%s trigger=%s timelapse_active=%s",
|
||||
printer_id,
|
||||
trigger,
|
||||
timelapse_was_active,
|
||||
)
|
||||
|
||||
# If a timelapse is actively recording, skip the pre-capture — the
|
||||
# post-completion path will extract the last frame from the recorded
|
||||
# video, which still provides the best framing (toolhead parked,
|
||||
# before bed drop) without the per-layer parking side effects.
|
||||
if timelapse_was_active:
|
||||
logger.info(
|
||||
"[FINISH-PHOTO-MOMENT] timelapse active for printer %s — skipping pre-capture (last-frame extraction will run post-completion)",
|
||||
printer_id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
capture_setting = await get_setting(db, "capture_finish_photo")
|
||||
if capture_setting is not None and capture_setting.lower() != "true":
|
||||
logger.info("[FINISH-PHOTO-MOMENT] capture_finish_photo disabled — skipping pre-capture")
|
||||
return
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if printer is None:
|
||||
logger.warning(
|
||||
"[FINISH-PHOTO-MOMENT] printer %s not found in DB",
|
||||
printer_id,
|
||||
)
|
||||
return
|
||||
|
||||
frame_bytes: bytes | None = None
|
||||
|
||||
if printer.external_camera_enabled and printer.external_camera_url:
|
||||
from backend.app.services.external_camera import capture_frame
|
||||
|
||||
frame_bytes = await capture_frame(
|
||||
printer.external_camera_url,
|
||||
printer.external_camera_type or "mjpeg",
|
||||
snapshot_url=printer.external_camera_snapshot_url,
|
||||
)
|
||||
if frame_bytes:
|
||||
logger.info(
|
||||
"[FINISH-PHOTO-MOMENT] captured external-camera frame (%d bytes)",
|
||||
len(frame_bytes),
|
||||
)
|
||||
else:
|
||||
from backend.app.api.routes.camera import get_buffered_frame
|
||||
|
||||
buffered = get_buffered_frame(printer_id)
|
||||
if buffered:
|
||||
frame_bytes = buffered
|
||||
logger.info(
|
||||
"[FINISH-PHOTO-MOMENT] used buffered RTSP frame (%d bytes)",
|
||||
len(frame_bytes),
|
||||
)
|
||||
else:
|
||||
from backend.app.services.camera import capture_camera_frame_bytes
|
||||
|
||||
frame_bytes = await capture_camera_frame_bytes(
|
||||
ip_address=printer.ip_address,
|
||||
access_code=printer.access_code,
|
||||
model=printer.model,
|
||||
timeout=15,
|
||||
)
|
||||
if frame_bytes:
|
||||
logger.info(
|
||||
"[FINISH-PHOTO-MOMENT] captured RTSP frame (%d bytes)",
|
||||
len(frame_bytes),
|
||||
)
|
||||
|
||||
if frame_bytes:
|
||||
_stage22_finish_frames[printer_id] = frame_bytes
|
||||
else:
|
||||
logger.warning(
|
||||
"[FINISH-PHOTO-MOMENT] no frame captured for printer %s — post-completion fallback will retry",
|
||||
printer_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[FINISH-PHOTO-MOMENT] pre-capture failed for printer %s: %s",
|
||||
printer_id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def on_print_complete(printer_id: int, data: dict):
|
||||
"""Handle print completion - update the archive status."""
|
||||
import time
|
||||
@@ -4452,7 +4482,11 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
# recording — it captures the moment after the toolhead parks
|
||||
# but before the bed drops, which the live-camera grab below
|
||||
# would miss (#1397). Skipped for external cameras (those have
|
||||
# their own framing and don't see a Bambu timelapse).
|
||||
# their own framing and don't see a Bambu timelapse). Only
|
||||
# runs when the USER explicitly enabled timelapse for this
|
||||
# print — #1721 removed Bambuddy's force-on at dispatch
|
||||
# because it caused per-layer nozzle parking on Smooth-mode
|
||||
# slicer profiles.
|
||||
prefer_timelapse_source = bool(data.get("timelapse_was_active")) and not (
|
||||
printer.external_camera_enabled and printer.external_camera_url
|
||||
)
|
||||
@@ -4463,6 +4497,27 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
archive_dir=archive_dir,
|
||||
)
|
||||
|
||||
# #1721: replacement framing path — on_finish_photo_moment
|
||||
# pre-captured a frame at the stage-22 / FINISH edge (toolhead
|
||||
# parked, bed not yet dropped) and cached the JPEG bytes in
|
||||
# _stage22_finish_frames. Consume them now so the saved photo
|
||||
# has the better framing instead of the post-bed-drop angle
|
||||
# the live-camera fallback below would give.
|
||||
if not photo_filename:
|
||||
cached_frame = _stage22_finish_frames.pop(printer_id, None)
|
||||
if cached_frame:
|
||||
photos_dir = archive_dir / "photos"
|
||||
photos_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
photo_filename = f"finish_{timestamp}_{uuid.uuid4().hex[:8]}.jpg"
|
||||
photo_path = photos_dir / photo_filename
|
||||
await asyncio.to_thread(photo_path.write_bytes, cached_frame)
|
||||
logger.info(
|
||||
"[PHOTO-BG] Saved stage-22 pre-captured frame: %s (%d bytes)",
|
||||
photo_filename,
|
||||
len(cached_frame),
|
||||
)
|
||||
|
||||
# Fallback chain: external camera → buffered live frame →
|
||||
# fresh RTSP capture. Only runs if the timelapse path above
|
||||
# didn't already produce a photo.
|
||||
@@ -4522,16 +4577,6 @@ async def on_print_complete(printer_id: int, data: dict):
|
||||
await db.commit()
|
||||
logger.info("[PHOTO-BG] Saved: %s", photo_filename)
|
||||
|
||||
# When Bambuddy forced timelapse on for this print, delete
|
||||
# the timelapse afterward (#1397). The user didn't ask for
|
||||
# a video to keep — only the finish photo. Runs even when
|
||||
# photo extraction failed, so we don't leave debris.
|
||||
if archive.bambuddy_forced_timelapse:
|
||||
await _cleanup_forced_timelapse(
|
||||
archive_id=archive_id,
|
||||
printer_id=printer_id,
|
||||
)
|
||||
|
||||
if photo_filename:
|
||||
return photo_filename
|
||||
return None
|
||||
@@ -5456,6 +5501,7 @@ async def lifespan(app: FastAPI):
|
||||
printer_manager.set_print_start_callback(on_print_start)
|
||||
printer_manager.set_print_complete_callback(on_print_complete)
|
||||
printer_manager.set_print_running_observed_callback(on_print_running_observed)
|
||||
printer_manager.set_finish_photo_moment_callback(on_finish_photo_moment)
|
||||
printer_manager.set_ams_change_callback(on_ams_change)
|
||||
|
||||
# Rehydrate persisted awaiting-plate-clear gate (#961) so prompts survive restarts
|
||||
|
||||
@@ -493,17 +493,6 @@ class BackgroundDispatchService:
|
||||
if self._is_cancel_requested(job.id):
|
||||
raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
|
||||
|
||||
async def _resolve_effective_timelapse(self, db, archive, job: PrintDispatchJob) -> bool:
|
||||
"""Dispatch-flow wrapper around the shared resolver (#1397).
|
||||
|
||||
Returns the effective value to pass to ``start_print(timelapse=...)``.
|
||||
"""
|
||||
return await resolve_effective_timelapse(
|
||||
db,
|
||||
archive,
|
||||
user_wanted_timelapse=bool(job.options.get("timelapse", False)),
|
||||
)
|
||||
|
||||
def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
processing = len(self._active_jobs)
|
||||
dispatched = len(self._queued_jobs)
|
||||
@@ -685,7 +674,7 @@ class BackgroundDispatchService:
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
|
||||
effective_timelapse = bool(job.options.get("timelapse", False))
|
||||
|
||||
await self._set_active_message(job, f"Starting print on {printer_name}...")
|
||||
started = printer_manager.start_print(
|
||||
@@ -892,7 +881,7 @@ class BackgroundDispatchService:
|
||||
|
||||
self._raise_if_cancel_requested(job)
|
||||
|
||||
effective_timelapse = await self._resolve_effective_timelapse(db, archive, job)
|
||||
effective_timelapse = bool(job.options.get("timelapse", False))
|
||||
|
||||
await self._set_active_message(job, f"Starting print on {printer_name}...")
|
||||
started = printer_manager.start_print(
|
||||
@@ -1108,41 +1097,4 @@ class BackgroundDispatchService:
|
||||
return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
|
||||
|
||||
|
||||
async def resolve_effective_timelapse(db, archive, user_wanted_timelapse: bool) -> bool:
|
||||
"""Resolve whether this print should record a timelapse (#1397).
|
||||
|
||||
Shared by both the on-demand dispatch path (``background_dispatch.py``,
|
||||
used by Print Now / Reprint flows) and the queued-dispatch path
|
||||
(``print_scheduler.py``, used by the print queue). Both must apply the
|
||||
same override semantics or the queue path's prints would slip through
|
||||
without a finish photo.
|
||||
|
||||
Bambuddy forces timelapse recording on when:
|
||||
- the global ``capture_finish_photo`` setting is enabled, AND
|
||||
- the user did NOT opt in to a timelapse for this specific print
|
||||
|
||||
The forced bit is recorded on the archive so the post-extraction
|
||||
cleanup path can delete the timelapse afterward (the user didn't
|
||||
ask for a video to keep, only the framed finish photo, #1397).
|
||||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
if user_wanted_timelapse:
|
||||
return True
|
||||
|
||||
# User didn't ask — check the master capture-finish-photo toggle.
|
||||
capture_setting = await get_setting(db, "capture_finish_photo")
|
||||
capture_enabled = capture_setting is None or capture_setting.lower() == "true"
|
||||
if not capture_enabled:
|
||||
return False
|
||||
|
||||
archive.bambuddy_forced_timelapse = True
|
||||
await db.commit()
|
||||
logging.getLogger(__name__).info(
|
||||
"[FORCED-TIMELAPSE] Forcing timelapse on for archive %s (capture_finish_photo enabled, user did not opt in)",
|
||||
archive.id,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
background_dispatch = BackgroundDispatchService()
|
||||
|
||||
@@ -436,6 +436,7 @@ class BambuMQTTClient:
|
||||
on_bed_temp_update: Callable[[float], None] | None = None,
|
||||
on_drying_complete: Callable[[int], None] | None = None,
|
||||
on_print_running_observed: Callable[[dict], None] | None = None,
|
||||
on_finish_photo_moment: Callable[[dict], None] | None = None,
|
||||
):
|
||||
self.ip_address = ip_address
|
||||
self.serial_number = serial_number
|
||||
@@ -459,6 +460,17 @@ class BambuMQTTClient:
|
||||
# the same shape as on_print_start (filename / subtask_name /
|
||||
# remaining_time / raw_data / ams_mapping).
|
||||
self.on_print_running_observed = on_print_running_observed
|
||||
# #1721: fired the moment the printer enters the end-of-print
|
||||
# "Filament unloading" phase (stg_cur=22 while progress>=99 or
|
||||
# we've hit the last layer / remaining_time<=0). This is the
|
||||
# framing #1397 was after — toolhead parked, bed not yet
|
||||
# dropped — but reached via a clean state signal instead of
|
||||
# the per-layer M622 J1 macros which caused per-layer nozzle
|
||||
# parks on slicer profiles with Timelapse Type = Smooth.
|
||||
# A FINISH-state fallback below fires this same callback if
|
||||
# stage 22 never arrives (cancel mid-print, external-spool-
|
||||
# only prints, HMS halt before unload, firmware variants).
|
||||
self.on_finish_photo_moment = on_finish_photo_moment
|
||||
# Per-AMS previous dry_time, used to detect the falling edge above.
|
||||
# Seeded lazily as we observe each AMS unit.
|
||||
self._previous_dry_times: dict[int, int] = {}
|
||||
@@ -471,6 +483,10 @@ class BambuMQTTClient:
|
||||
self._was_running: bool = False # Track if we've seen RUNNING state for current print
|
||||
self._completion_triggered: bool = False # Prevent duplicate completion triggers
|
||||
self._timelapse_during_print: bool = False # Track if timelapse was active during this print
|
||||
# #1721: one-shot guard so the end-of-print stage-22 detector
|
||||
# and the FINISH-state fallback don't both fire on the same
|
||||
# print. Reset to False on every print start.
|
||||
self._finish_photo_captured: bool = False
|
||||
self._last_valid_progress: float = 0.0 # Last non-zero progress (firmware resets on cancel)
|
||||
self._last_valid_layer_num: int = 0 # Last non-zero layer (firmware resets on cancel)
|
||||
# The subtask_id minted for the most recent start_print() command. The
|
||||
@@ -2107,12 +2123,46 @@ class BambuMQTTClient:
|
||||
# Calibration stage tracking
|
||||
if "stg_cur" in data:
|
||||
new_stg = data["stg_cur"]
|
||||
prev_stg = self.state.stg_cur
|
||||
# Always log ANY stg_cur change for debugging filament operations
|
||||
if new_stg != self.state.stg_cur:
|
||||
if new_stg != prev_stg:
|
||||
logger.debug(
|
||||
f"[{self.serial_number}] stg_cur changed: {self.state.stg_cur} -> {new_stg} ({get_stage_name(new_stg)})"
|
||||
f"[{self.serial_number}] stg_cur changed: {prev_stg} -> {new_stg} ({get_stage_name(new_stg)})"
|
||||
)
|
||||
self.state.stg_cur = new_stg
|
||||
# #1721 end-of-print finish photo trigger.
|
||||
# Stage 22 = "Filament unloading" fires at end-of-print AND
|
||||
# during mid-print color swaps. The end-of-print gate
|
||||
# (progress>=99 / layer>=total / remaining<=0) disambiguates
|
||||
# — those signals only line up at the real end. Edge-only
|
||||
# (prev != 22) so the trigger fires once per stage entry.
|
||||
if (
|
||||
new_stg == 22
|
||||
and prev_stg != 22
|
||||
and self._was_running
|
||||
and not self._finish_photo_captured
|
||||
and self.on_finish_photo_moment
|
||||
):
|
||||
progress = self.state.progress or 0.0
|
||||
layer_num = self.state.layer_num or 0
|
||||
total_layers = self.state.total_layers or 0
|
||||
remaining = self.state.remaining_time or 0
|
||||
is_end_of_print = progress >= 99 or (total_layers > 0 and layer_num >= total_layers) or remaining <= 0
|
||||
if is_end_of_print:
|
||||
self._finish_photo_captured = True
|
||||
logger.info(
|
||||
f"[{self.serial_number}] FINISH PHOTO MOMENT (stage-22) — "
|
||||
f"progress={progress}, layer={layer_num}/{total_layers}, "
|
||||
f"remaining={remaining}min, timelapse_active={self._timelapse_during_print}"
|
||||
)
|
||||
self.on_finish_photo_moment(
|
||||
{
|
||||
"trigger": "stage_22",
|
||||
"filename": self._previous_gcode_file or self.state.gcode_file,
|
||||
"subtask_name": self.state.subtask_name,
|
||||
"timelapse_was_active": self._timelapse_during_print,
|
||||
}
|
||||
)
|
||||
if "stg" in data:
|
||||
self.state.stg = data["stg"] if isinstance(data["stg"], list) else []
|
||||
|
||||
@@ -3024,6 +3074,8 @@ class BambuMQTTClient:
|
||||
# Reset completion tracking for new print
|
||||
self._was_running = True
|
||||
self._completion_triggered = False
|
||||
# #1721: rearm the end-of-print finish-photo trigger for the new print
|
||||
self._finish_photo_captured = False
|
||||
# Reset last valid progress/layer for usage tracking
|
||||
self._last_valid_progress = 0.0
|
||||
self._last_valid_layer_num = 0
|
||||
@@ -3135,6 +3187,26 @@ class BambuMQTTClient:
|
||||
f"timelapse_during_print: {self._timelapse_during_print}"
|
||||
)
|
||||
timelapse_was_active = self._timelapse_during_print
|
||||
# #1721 fallback: if the stage-22 trigger never fired (cancel,
|
||||
# external-spool-only, HMS halt, or firmware variant that skips
|
||||
# the unload phase) fire the finish-photo moment now. Bed has
|
||||
# already dropped, framing is worse, but we still capture.
|
||||
# Only on successful completion — aborted/failed prints don't
|
||||
# produce a meaningful finish photo.
|
||||
if status == "completed" and not self._finish_photo_captured and self.on_finish_photo_moment:
|
||||
self._finish_photo_captured = True
|
||||
logger.info(
|
||||
f"[{self.serial_number}] FINISH PHOTO MOMENT (FINISH fallback) — "
|
||||
f"stage-22 never fired; capturing at FINISH-state transition"
|
||||
)
|
||||
self.on_finish_photo_moment(
|
||||
{
|
||||
"trigger": "finish_state",
|
||||
"filename": self._previous_gcode_file or current_file,
|
||||
"subtask_name": self.state.subtask_name,
|
||||
"timelapse_was_active": timelapse_was_active,
|
||||
}
|
||||
)
|
||||
self._completion_triggered = True
|
||||
self._was_running = False
|
||||
self._timelapse_during_print = False # Reset for next print
|
||||
|
||||
@@ -2187,21 +2187,13 @@ class PrintScheduler:
|
||||
pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
|
||||
pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
|
||||
|
||||
# #1397: force timelapse on when capture_finish_photo is enabled so
|
||||
# the finish-photo extractor has something to pull from. Same override
|
||||
# semantics as background_dispatch.py — both queue paths must apply
|
||||
# the same rule or queued prints slip through without a finish photo.
|
||||
# When archive_print failed (library_file path, line 1968 except), we
|
||||
# have no archive to mark — fall back to the literal user choice; the
|
||||
# downstream finish-photo path can't run without an archive anyway.
|
||||
if archive is not None:
|
||||
from backend.app.services.background_dispatch import resolve_effective_timelapse
|
||||
|
||||
effective_timelapse = await resolve_effective_timelapse(
|
||||
db, archive, user_wanted_timelapse=bool(item.timelapse)
|
||||
)
|
||||
else:
|
||||
effective_timelapse = bool(item.timelapse)
|
||||
# #1721: respect the user's explicit timelapse choice. The #1397
|
||||
# force-on at dispatch was removed because it caused per-layer nozzle
|
||||
# parking on slicer profiles with Timelapse Type = Smooth. Finish-photo
|
||||
# capture is now driven by the stg_cur=22 transition in bambu_mqtt.py
|
||||
# ("Filament unloading", toolhead parked, bed not yet dropped) with a
|
||||
# FINISH-state fallback — no need to force a video.
|
||||
effective_timelapse = bool(item.timelapse)
|
||||
|
||||
# Start the print with AMS mapping, plate_id and print options
|
||||
started = printer_manager.start_print(
|
||||
|
||||
@@ -173,6 +173,7 @@ class PrinterManager:
|
||||
self._on_print_start: Callable[[int, dict], None] | None = None
|
||||
self._on_print_complete: Callable[[int, dict], None] | None = None
|
||||
self._on_print_running_observed: Callable[[int, dict], None] | None = None
|
||||
self._on_finish_photo_moment: Callable[[int, dict], None] | None = None
|
||||
self._on_status_change: Callable[[int, PrinterState], None] | None = None
|
||||
self._on_ams_change: Callable[[int, list], None] | None = None
|
||||
self._on_layer_change: Callable[[int, int], None] | None = None
|
||||
@@ -322,6 +323,19 @@ class PrinterManager:
|
||||
hook to recover."""
|
||||
self._on_print_running_observed = callback
|
||||
|
||||
def set_finish_photo_moment_callback(self, callback: Callable[[int, dict], None]):
|
||||
"""Set callback for the #1721 finish-photo moment.
|
||||
|
||||
Fires on the stage-22 (\"Filament unloading\") edge at end-of-print
|
||||
— the framing window where the toolhead is parked but the bed
|
||||
hasn't dropped yet. Falls back to firing at the FINISH-state
|
||||
transition for prints that skip stage 22 (cancel, external-spool-
|
||||
only, HMS halt, firmware variants). Payload includes the
|
||||
``trigger`` key (``\"stage_22\"`` or ``\"finish_state\"``) and
|
||||
``timelapse_was_active`` so the photo path can choose between
|
||||
live-camera capture and timelapse last-frame extraction."""
|
||||
self._on_finish_photo_moment = callback
|
||||
|
||||
def set_status_change_callback(self, callback: Callable[[int, PrinterState], None]):
|
||||
"""Set callback for status change events."""
|
||||
self._on_status_change = callback
|
||||
@@ -389,6 +403,10 @@ class PrinterManager:
|
||||
if self._on_print_running_observed:
|
||||
self._schedule_async(self._on_print_running_observed(printer_id, data))
|
||||
|
||||
def on_finish_photo_moment(data: dict):
|
||||
if self._on_finish_photo_moment:
|
||||
self._schedule_async(self._on_finish_photo_moment(printer_id, data))
|
||||
|
||||
def on_ams_change(ams_data: list):
|
||||
if self._on_ams_change:
|
||||
self._schedule_async(self._on_ams_change(printer_id, ams_data))
|
||||
@@ -418,6 +436,7 @@ class PrinterManager:
|
||||
on_bed_temp_update=on_bed_temp_update,
|
||||
on_drying_complete=on_drying_complete,
|
||||
on_print_running_observed=on_print_running_observed,
|
||||
on_finish_photo_moment=on_finish_photo_moment,
|
||||
)
|
||||
|
||||
client.connect()
|
||||
|
||||
@@ -231,11 +231,11 @@ def test_dispatch_option_defaults_align_with_request_schema_defaults():
|
||||
from backend.app.schemas.library import FilePrintRequest
|
||||
from backend.app.services import background_dispatch as bd
|
||||
|
||||
# `timelapse` deliberately excluded — the dispatcher now resolves it via
|
||||
# ``_resolve_effective_timelapse`` so the value passed to ``start_print``
|
||||
# depends on the ``capture_finish_photo`` setting + ``bambuddy_forced_timelapse``
|
||||
# column (#1397). The original literal `job.options.get("timelapse", False)`
|
||||
# pattern no longer appears.
|
||||
# `timelapse` deliberately excluded — the dispatcher wraps it in
|
||||
# ``bool(...)`` (``effective_timelapse = bool(job.options.get("timelapse",
|
||||
# False))``) so the bare-pattern needle in the loop below would miss it.
|
||||
# The wrap exists to coerce None / non-bool option payloads to a bool
|
||||
# boundary the printer firmware accepts (#1721 follow-up).
|
||||
fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "use_ams")
|
||||
reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
|
||||
libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
|
||||
|
||||
@@ -606,14 +606,6 @@ class TestReprintArchiveDispatchWiring:
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("backend.app.main.register_expected_print"),
|
||||
# #1397: _resolve_effective_timelapse touches DB + setting layer
|
||||
# that this watchdog-focused test isn't equipped to mock. Stub
|
||||
# the whole helper so the dispatch flow proceeds unaffected.
|
||||
patch.object(
|
||||
BackgroundDispatchService,
|
||||
"_resolve_effective_timelapse",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
pytest.raises(RuntimeError, match="did not acknowledge print command"),
|
||||
):
|
||||
await service._run_reprint_archive(job)
|
||||
@@ -682,14 +674,6 @@ class TestReprintArchiveDispatchWiring:
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("backend.app.main.register_expected_print"),
|
||||
# #1397: _resolve_effective_timelapse touches DB + setting layer
|
||||
# that this watchdog-focused test isn't equipped to mock. Stub
|
||||
# the whole helper so the dispatch flow proceeds unaffected.
|
||||
patch.object(
|
||||
BackgroundDispatchService,
|
||||
"_resolve_effective_timelapse",
|
||||
new=AsyncMock(return_value=False),
|
||||
),
|
||||
):
|
||||
await service._run_reprint_archive(job) # must not raise
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Tests for _resolve_effective_timelapse (#1397).
|
||||
|
||||
Bambuddy forces timelapse recording on at dispatch time when the
|
||||
capture_finish_photo setting is enabled and the user did not opt in
|
||||
to timelapse for the specific print. The forced bit is recorded on
|
||||
the archive so the post-extraction cleanup path can delete the file.
|
||||
|
||||
These tests exercise the four decision shapes the helper has to handle:
|
||||
|
||||
1. capture_finish_photo OFF → no override regardless of user choice
|
||||
2. capture_finish_photo ON, user chose timelapse → no override (the
|
||||
user's choice already covers the photo path)
|
||||
3. capture_finish_photo ON, user chose NO timelapse → override to ON,
|
||||
mark archive.bambuddy_forced_timelapse=True
|
||||
4. capture_finish_photo unset (None / missing) → defaults to ON, so
|
||||
the same override applies as case 3
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app.services.background_dispatch import (
|
||||
BackgroundDispatchService,
|
||||
PrintDispatchJob,
|
||||
)
|
||||
|
||||
|
||||
def _make_job(timelapse: bool | None) -> PrintDispatchJob:
|
||||
"""Mint a job with the smallest valid shape — the only field
|
||||
_resolve_effective_timelapse reads from job is `options`."""
|
||||
return PrintDispatchJob(
|
||||
id=1,
|
||||
kind="print_library_file",
|
||||
source_id=42,
|
||||
source_name="test.gcode.3mf",
|
||||
printer_id=10,
|
||||
printer_name="Printer A",
|
||||
options={"timelapse": timelapse} if timelapse is not None else {},
|
||||
)
|
||||
|
||||
|
||||
def _make_archive() -> SimpleNamespace:
|
||||
"""Stand-in archive object; the helper only touches .id and
|
||||
.bambuddy_forced_timelapse."""
|
||||
return SimpleNamespace(id=99, bambuddy_forced_timelapse=False)
|
||||
|
||||
|
||||
def _make_db() -> AsyncMock:
|
||||
"""Fake db with a no-op .commit()."""
|
||||
db = AsyncMock()
|
||||
return db
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_finish_photo_off_means_no_override():
|
||||
"""Master toggle off → user's timelapse=False stays False, no flag set."""
|
||||
service = BackgroundDispatchService()
|
||||
archive = _make_archive()
|
||||
db = _make_db()
|
||||
job = _make_job(timelapse=False)
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new=AsyncMock(return_value="false"),
|
||||
):
|
||||
effective = await service._resolve_effective_timelapse(db, archive, job)
|
||||
|
||||
assert effective is False
|
||||
assert archive.bambuddy_forced_timelapse is False
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_opted_in_passes_through_unchanged():
|
||||
"""User asked for a timelapse → no override needed (their normal flow
|
||||
already records one). bambuddy_forced_timelapse stays False so cleanup
|
||||
leaves the file alone."""
|
||||
service = BackgroundDispatchService()
|
||||
archive = _make_archive()
|
||||
db = _make_db()
|
||||
job = _make_job(timelapse=True)
|
||||
|
||||
# get_setting shouldn't even be consulted — but if it is, no override
|
||||
# should still fire.
|
||||
with patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new=AsyncMock(return_value="true"),
|
||||
):
|
||||
effective = await service._resolve_effective_timelapse(db, archive, job)
|
||||
|
||||
assert effective is True
|
||||
assert archive.bambuddy_forced_timelapse is False
|
||||
db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_on_user_off_forces_timelapse_and_marks_flag():
|
||||
"""The whole point of the fix: capture_finish_photo=on + user-timelapse=off
|
||||
flips the MQTT command to timelapse=True and marks the archive for
|
||||
post-extraction cleanup."""
|
||||
service = BackgroundDispatchService()
|
||||
archive = _make_archive()
|
||||
db = _make_db()
|
||||
job = _make_job(timelapse=False)
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new=AsyncMock(return_value="true"),
|
||||
):
|
||||
effective = await service._resolve_effective_timelapse(db, archive, job)
|
||||
|
||||
assert effective is True
|
||||
assert archive.bambuddy_forced_timelapse is True
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capture_finish_photo_unset_defaults_to_enabled():
|
||||
"""Setting absent from DB → default is True (per the Field default in the
|
||||
schema), so the override fires just like when explicitly enabled."""
|
||||
service = BackgroundDispatchService()
|
||||
archive = _make_archive()
|
||||
db = _make_db()
|
||||
job = _make_job(timelapse=False)
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new=AsyncMock(return_value=None),
|
||||
):
|
||||
effective = await service._resolve_effective_timelapse(db, archive, job)
|
||||
|
||||
assert effective is True
|
||||
assert archive.bambuddy_forced_timelapse is True
|
||||
db.commit.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_missing_timelapse_treated_as_false():
|
||||
"""Some queue paths pass options without a timelapse key. Treat absent
|
||||
as False (matches existing job.options.get('timelapse', False) default
|
||||
that the caller previously used)."""
|
||||
service = BackgroundDispatchService()
|
||||
archive = _make_archive()
|
||||
db = _make_db()
|
||||
job = _make_job(timelapse=None) # falls through to {}
|
||||
|
||||
with patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new=AsyncMock(return_value="true"),
|
||||
):
|
||||
effective = await service._resolve_effective_timelapse(db, archive, job)
|
||||
|
||||
assert effective is True
|
||||
assert archive.bambuddy_forced_timelapse is True
|
||||
@@ -1,291 +0,0 @@
|
||||
"""Tests for _cleanup_forced_timelapse (#1397).
|
||||
|
||||
When Bambuddy forced timelapse on for the finish-photo path, this helper
|
||||
runs after the extractor (success OR failure — we never leave debris).
|
||||
It deletes:
|
||||
- the locally-attached file (clears archive.timelapse_path)
|
||||
- the printer-side file via FTP DELE, walking the four scanner dirs
|
||||
|
||||
These tests pin the four branches:
|
||||
|
||||
1. archive doesn't exist → no-op
|
||||
2. archive exists but bambuddy_forced_timelapse=False → no-op (user wanted
|
||||
the timelapse)
|
||||
3. archive exists, forced=True, local file present → delete local + DB
|
||||
update + FTP DELE on the first directory that succeeds
|
||||
4. archive exists, forced=True, but FTP DELE fails on every dir → local
|
||||
side still cleaned up; warn log emitted (best-effort)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.app import main as main_module
|
||||
from backend.app.main import _cleanup_forced_timelapse
|
||||
from backend.app.services.bambu_ftp import DeleteResult
|
||||
|
||||
|
||||
def _fake_session_factory(rows: dict):
|
||||
"""Return an async_session() replacement that yields the given rows.
|
||||
|
||||
`rows` is a mapping of model -> object that the test wants returned
|
||||
from `db.execute(select(...)).scalar_one_or_none()`. The select
|
||||
target is detected by walking the column descriptions — for these
|
||||
tests we just look at the model class name.
|
||||
"""
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_session():
|
||||
async def execute(stmt):
|
||||
# The select(...) statement carries the target entity in
|
||||
# `stmt.column_descriptions[0]["entity"]`. Match by class name.
|
||||
target_name = stmt.column_descriptions[0]["entity"].__name__
|
||||
row = rows.get(target_name)
|
||||
return SimpleNamespace(scalar_one_or_none=lambda: row)
|
||||
|
||||
commits: list[None] = []
|
||||
|
||||
async def commit():
|
||||
commits.append(None)
|
||||
|
||||
yield SimpleNamespace(execute=execute, commit=commit, _commits=commits)
|
||||
|
||||
return fake_session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_app_settings(monkeypatch, tmp_path):
|
||||
"""Point base_dir at a tmp_path so the helper can resolve relative
|
||||
timelapse paths against a real fs we control."""
|
||||
monkeypatch.setattr(main_module.app_settings, "base_dir", tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_archive_is_noop(monkeypatch):
|
||||
"""Archive deleted between print start and cleanup? Don't crash."""
|
||||
monkeypatch.setattr(main_module, "async_session", _fake_session_factory({"PrintArchive": None, "Printer": None}))
|
||||
delete_mock = AsyncMock()
|
||||
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
delete_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_not_forced_is_noop(monkeypatch, tmp_path):
|
||||
"""User wanted a timelapse → don't delete anything."""
|
||||
archive = SimpleNamespace(
|
||||
bambuddy_forced_timelapse=False,
|
||||
timelapse_path="archive/1/timelapse.mp4",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"async_session",
|
||||
_fake_session_factory({"PrintArchive": archive, "Printer": None}),
|
||||
)
|
||||
|
||||
# Lay down a real file so we'd detect a stray delete.
|
||||
video_path = tmp_path / archive.timelapse_path
|
||||
video_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
video_path.write_bytes(b"x" * 100)
|
||||
|
||||
delete_mock = AsyncMock(return_value=DeleteResult.DELETED)
|
||||
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
|
||||
delete_mock.assert_not_awaited()
|
||||
assert video_path.exists()
|
||||
# archive.timelapse_path is untouched — we still have the user's video
|
||||
# tracked correctly.
|
||||
assert archive.timelapse_path == "archive/1/timelapse.mp4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_deletes_local_and_remote(monkeypatch, tmp_path):
|
||||
"""Happy path: forced=True → local file unlinked, DB row cleared, FTP
|
||||
DELE called against /timelapse/<filename> (the first dir to succeed)."""
|
||||
archive = SimpleNamespace(
|
||||
bambuddy_forced_timelapse=True,
|
||||
timelapse_path="archive/1/myprint.mp4",
|
||||
)
|
||||
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"async_session",
|
||||
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
|
||||
)
|
||||
|
||||
video_path = tmp_path / archive.timelapse_path
|
||||
video_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
video_path.write_bytes(b"x" * 100)
|
||||
|
||||
# FTP DELE succeeds on the first directory we try.
|
||||
delete_mock = AsyncMock(return_value=DeleteResult.DELETED)
|
||||
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
|
||||
# Local side: file gone, DB cleared.
|
||||
assert not video_path.exists()
|
||||
assert archive.timelapse_path is None
|
||||
# Remote side: DELE'd against /timelapse/myprint.mp4 — that's the
|
||||
# first dir the cleanup tries.
|
||||
delete_mock.assert_awaited()
|
||||
call = delete_mock.await_args
|
||||
assert call.args[0] == "10.0.0.5"
|
||||
assert call.args[1] == "12345678"
|
||||
assert call.args[2] == "/timelapse/myprint.mp4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_walks_alternate_dirs_when_first_fails(monkeypatch, tmp_path):
|
||||
"""If /timelapse/ DELE returns False (file not there), try the other
|
||||
scanner dirs in order."""
|
||||
archive = SimpleNamespace(
|
||||
bambuddy_forced_timelapse=True,
|
||||
timelapse_path="archive/1/myprint.mp4",
|
||||
)
|
||||
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"async_session",
|
||||
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
|
||||
)
|
||||
|
||||
video_path = tmp_path / archive.timelapse_path
|
||||
video_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
video_path.write_bytes(b"x" * 100)
|
||||
|
||||
# First two dirs report NOT_FOUND (file not there), third succeeds.
|
||||
# Cleanup should stop after the third — and crucially must NOT WARN
|
||||
# because no real network/auth failure happened (#1721).
|
||||
delete_mock = AsyncMock(side_effect=[DeleteResult.NOT_FOUND, DeleteResult.NOT_FOUND, DeleteResult.DELETED])
|
||||
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
|
||||
assert delete_mock.await_count == 3
|
||||
paths_tried = [call.args[2] for call in delete_mock.await_args_list]
|
||||
assert paths_tried == [
|
||||
"/timelapse/myprint.mp4",
|
||||
"/timelapse/video/myprint.mp4",
|
||||
"/record/myprint.mp4",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_local_cleanup_runs_even_if_ftp_unreachable(monkeypatch, tmp_path):
|
||||
"""FTP completely failing must not block local cleanup — the user's
|
||||
archive UI should reflect that the timelapse is gone immediately,
|
||||
even if the printer-side file lingers."""
|
||||
archive = SimpleNamespace(
|
||||
bambuddy_forced_timelapse=True,
|
||||
timelapse_path="archive/1/myprint.mp4",
|
||||
)
|
||||
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"async_session",
|
||||
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
|
||||
)
|
||||
|
||||
video_path = tmp_path / archive.timelapse_path
|
||||
video_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
video_path.write_bytes(b"x" * 100)
|
||||
|
||||
# Every FTP attempt throws.
|
||||
delete_mock = AsyncMock(side_effect=OSError("connection refused"))
|
||||
with patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
|
||||
# Local side cleaned up even though all FTP attempts threw.
|
||||
assert not video_path.exists()
|
||||
assert archive.timelapse_path is None
|
||||
# All four dirs were attempted before giving up.
|
||||
assert delete_mock.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_no_warning_when_every_dir_returns_not_found(monkeypatch, tmp_path, caplog):
|
||||
"""#1721: when every candidate dir returns 550 (file not there) the
|
||||
helper used to emit "Could not delete printer-side timelapse ...
|
||||
(file may already be gone)" at WARNING. That message landed in support
|
||||
bundles for healthy printers whose firmware swept the SD card itself.
|
||||
With DeleteResult.NOT_FOUND signalling, no real failure happened →
|
||||
must be DEBUG, not WARNING.
|
||||
"""
|
||||
import logging
|
||||
|
||||
archive = SimpleNamespace(
|
||||
bambuddy_forced_timelapse=True,
|
||||
timelapse_path="archive/1/myprint.mp4",
|
||||
)
|
||||
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="N2S")
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"async_session",
|
||||
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
|
||||
)
|
||||
|
||||
video_path = tmp_path / archive.timelapse_path
|
||||
video_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
video_path.write_bytes(b"x" * 100)
|
||||
|
||||
delete_mock = AsyncMock(return_value=DeleteResult.NOT_FOUND)
|
||||
with (
|
||||
caplog.at_level(logging.DEBUG, logger="backend.app.main"),
|
||||
patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock),
|
||||
):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
|
||||
assert delete_mock.await_count == 4
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "[FORCED-TIMELAPSE]" in r.message]
|
||||
assert warnings == [], f"unexpected WARNING(s): {[w.message for w in warnings]}"
|
||||
debugs = [
|
||||
r for r in caplog.records if r.levelno == logging.DEBUG and "No printer-side timelapse to delete" in r.message
|
||||
]
|
||||
assert len(debugs) == 1, "expected the 'nothing to delete' debug summary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_warns_when_any_dir_returns_failed(monkeypatch, tmp_path, caplog):
|
||||
"""Counterpart to the above: a real network/auth/transient FAILED on any
|
||||
dir keeps the WARNING — that's the signal the maintainer actually wants
|
||||
to see.
|
||||
"""
|
||||
import logging
|
||||
|
||||
archive = SimpleNamespace(
|
||||
bambuddy_forced_timelapse=True,
|
||||
timelapse_path="archive/1/myprint.mp4",
|
||||
)
|
||||
printer = SimpleNamespace(ip_address="10.0.0.5", access_code="12345678", model="O1C")
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"async_session",
|
||||
_fake_session_factory({"PrintArchive": archive, "Printer": printer}),
|
||||
)
|
||||
|
||||
video_path = tmp_path / archive.timelapse_path
|
||||
video_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
video_path.write_bytes(b"x" * 100)
|
||||
|
||||
delete_mock = AsyncMock(
|
||||
side_effect=[
|
||||
DeleteResult.NOT_FOUND,
|
||||
DeleteResult.FAILED,
|
||||
DeleteResult.NOT_FOUND,
|
||||
DeleteResult.NOT_FOUND,
|
||||
]
|
||||
)
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger="backend.app.main"),
|
||||
patch("backend.app.services.bambu_ftp.delete_file_async", new=delete_mock),
|
||||
):
|
||||
await _cleanup_forced_timelapse(archive_id=99, printer_id=10)
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING and "[FORCED-TIMELAPSE]" in r.message]
|
||||
assert len(warnings) == 1
|
||||
assert "network/auth/transient" in warnings[0].message
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Regression test for the print-queue path of the #1397 force-timelapse fix.
|
||||
|
||||
The first round of #1397 only wired the override into ``background_dispatch.py``,
|
||||
which covers Print Now / Reprint Now flows. The print *queue* uses a separate
|
||||
scheduler at ``print_scheduler.py:_start_print`` that calls
|
||||
``printer_manager.start_print`` directly — and the first attempt skipped that
|
||||
call site, so queued prints' timelapse setting passed through unchanged and
|
||||
the finish-photo path had nothing to draw from. Field-test caught this when
|
||||
Martin queued two prints (H2D + X1C); neither got a forced timelapse and
|
||||
``archive.bambuddy_forced_timelapse`` stayed False on both.
|
||||
|
||||
This test pins the wiring at the source level: the helper is imported AND
|
||||
its return value is what ``start_print(timelapse=...)`` receives. We can't
|
||||
exercise the full ``_start_print`` method without standing up a real DB +
|
||||
printer_manager + ams_assignment fixture stack, but the structural assert
|
||||
is enough to catch regression at the dispatch hook.
|
||||
"""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
SCHEDULER_PATH = Path(__file__).resolve().parent.parent.parent / "app" / "services" / "print_scheduler.py"
|
||||
|
||||
|
||||
def _find_call_to_start_print(tree: ast.AST) -> ast.Call:
|
||||
"""Walk the AST and return the printer_manager.start_print(...) Call node
|
||||
inside _start_print. Should be exactly one."""
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if not isinstance(func, ast.Attribute):
|
||||
continue
|
||||
if func.attr != "start_print":
|
||||
continue
|
||||
value = func.value
|
||||
if not isinstance(value, ast.Name) or value.id != "printer_manager":
|
||||
continue
|
||||
return node
|
||||
raise AssertionError("Could not find printer_manager.start_print(...) call in print_scheduler.py")
|
||||
|
||||
|
||||
def test_start_print_timelapse_kwarg_uses_resolved_value():
|
||||
"""``timelapse=`` kwarg passed to start_print must reference
|
||||
``effective_timelapse`` (the resolved value) — not ``item.timelapse``
|
||||
(the user's raw choice). If a refactor drops the resolver call and
|
||||
restores ``item.timelapse``, this test fails."""
|
||||
source = SCHEDULER_PATH.read_text()
|
||||
tree = ast.parse(source)
|
||||
|
||||
call = _find_call_to_start_print(tree)
|
||||
timelapse_kw = next((kw for kw in call.keywords if kw.arg == "timelapse"), None)
|
||||
assert timelapse_kw is not None, "start_print(timelapse=...) kwarg is missing"
|
||||
|
||||
# The value must be the resolved variable, not item.timelapse.
|
||||
value = timelapse_kw.value
|
||||
assert isinstance(value, ast.Name) and value.id == "effective_timelapse", (
|
||||
f"timelapse= must be the resolver's return value (effective_timelapse), "
|
||||
f"got {ast.dump(value)}. The queue path must apply the same #1397 "
|
||||
f"override as background_dispatch.py — otherwise queued prints' "
|
||||
f"finish-photo extractor has nothing to pull from."
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_imports_resolve_effective_timelapse():
|
||||
"""The import must exist somewhere in print_scheduler.py — guards against
|
||||
a future refactor removing it and falling back to item.timelapse."""
|
||||
source = SCHEDULER_PATH.read_text()
|
||||
assert "from backend.app.services.background_dispatch import resolve_effective_timelapse" in source, (
|
||||
"print_scheduler.py must import resolve_effective_timelapse from background_dispatch"
|
||||
)
|
||||
Reference in New Issue
Block a user