From faff453dec85649bae7a1d60637a6b5c68171204 Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 23 Feb 2026 13:57:58 +0100 Subject: [PATCH] Fix bed cooled notification never triggering (#497) The bed cooldown monitor was defined at the end of on_print_complete, after an early return that exits when no archive is found. Prints started from BambuStudio or the printer's touchscreen have no archive, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown block (function def + task creation) to before the archive_id early-return so it fires for all completed prints. Also hardened the temperature dict check from truthiness to isinstance. --- CHANGELOG.md | 1 + backend/app/main.py | 162 +++++++++++++++++++------------------- backend/tests/conftest.py | 6 ++ 3 files changed, 89 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 988a05ac7..904b31fd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file. ## [0.2.1b3] - Unreleased ### Fixed +- **Print Bed Cooled Notification Never Triggers** ([#497](https://github.com/maziggy/bambuddy/issues/497)) — The bed cooldown monitor (which polls bed temperature after a print and sends a notification when it drops below the configured threshold) was defined at the end of the `on_print_complete` callback, after an early `return` that exits when no archive is found for the print. Prints started from BambuStudio or the printer's touchscreen typically have no archive in Bambuddy, so the function returned before the bed cooldown task was ever created. Moved the bed cooldown monitor to before the archive lookup early-return so it fires for all completed prints regardless of archive state. Also hardened the temperature dict check from truthiness (`if status.temperatures:`) to type check (`isinstance(status.temperatures, dict)`) to avoid false negatives on empty dicts. - **IP Addresses Not Redacted From Support Bundle Logs** — The `_sanitize_log_content()` function redacted emails, serials, and credentials but left raw IPv4 addresses in log output. Now adds known printer IPs to the sensitive string list for exact matching, and applies an IPv4 regex that replaces addresses with `[IP]` while preserving firmware version strings (which use leading-zero octets like `01.09.01.00`). Updated the system info page privacy disclaimer to list IP addresses as redacted. - **"Unknown stage (74)" on H2D During Print Preparation** — The H2D firmware reports `stg_cur=74` during print preparation, but this stage was not in the stage name lookup table (which went up to 66, sourced from BambuStudio). Now maps stage 74 to "Preparing". Also added stage 77 ("Preparing AMS") which was present in BambuStudio but missing from the lookup. - **Wrong Documentation Link for "Lubricate Carbon Rods" on P2S** ([#490](https://github.com/maziggy/bambuddy/issues/490)) — The "Lubricate Carbon Rods" maintenance task linked to the belt tension wiki page instead of the XYZ axis lubrication page for P2S printers. diff --git a/backend/app/main.py b/backend/app/main.py index 09ea71de6..d8ed896aa 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2282,6 +2282,88 @@ async def on_print_complete(printer_id: int, data: dict): log_timing("Queue item update") + # Start bed cooldown monitor (polls bed temp until it drops below threshold) + # Must run before archive_id early-return so it fires for all prints (including + # prints started from BambuStudio/touchscreen that have no archive). + async def _background_bed_cooldown(): + """Monitor bed temperature after print and notify when cooled.""" + try: + from backend.app.api.routes.settings import get_setting + + # Check threshold setting + async with async_session() as db: + threshold_str = await get_setting(db, "bed_cooled_threshold") + threshold = float(threshold_str) if threshold_str else 35.0 + + # Check if any provider has on_bed_cooled enabled (early exit if none) + async with async_session() as db: + providers = await notification_service._get_providers_for_event(db, "on_bed_cooled", printer_id) + if not providers: + logger.debug("[BED-COOL] No providers enabled for bed_cooled on printer %s", printer_id) + return + + logger.info("[BED-COOL] Monitoring bed temp for printer %s (threshold: %.0f°C)", printer_id, threshold) + + max_polls = 120 # 120 * 15s = 30 min timeout + for _ in range(max_polls): + await asyncio.sleep(15) + + # Check if printer is still connected + status = printer_manager.get_status(printer_id) + if status is None: + logger.info("[BED-COOL] Printer %s disconnected, stopping monitor", printer_id) + return + + # Check if a new print started (state == RUNNING) + if hasattr(status, "state") and status.state == "RUNNING": + logger.info("[BED-COOL] New print started on printer %s, stopping monitor", printer_id) + return + + # Get bed temperature + bed_temp = None + if hasattr(status, "temperatures") and isinstance(status.temperatures, dict): + bed_temp = status.temperatures.get("bed") + + if bed_temp is None: + continue + + if bed_temp <= threshold: + logger.info( + "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)", + bed_temp, + printer_id, + threshold, + ) + printer_info = printer_manager.get_printer(printer_id) + p_name = printer_info.name if printer_info else "Unknown" + async with async_session() as db: + await notification_service.on_bed_cooled( + printer_id=printer_id, + printer_name=p_name, + bed_temp=bed_temp, + threshold=threshold, + filename=filename or subtask_name or "", + db=db, + ) + return + + logger.info("[BED-COOL] Timeout waiting for bed to cool on printer %s", printer_id) + except asyncio.CancelledError: + logger.info("[BED-COOL] Bed cooldown monitor cancelled for printer %s", printer_id) + except Exception as e: + logger.warning("[BED-COOL] Failed: %s", e) + finally: + _bed_cooldown_tasks.pop(printer_id, None) + + # Only start bed cooldown for completed prints + if data.get("status") == "completed": + # Cancel any existing task for this printer + existing_task = _bed_cooldown_tasks.pop(printer_id, None) + if existing_task and not existing_task.done(): + existing_task.cancel() + task = asyncio.create_task(_background_bed_cooldown()) + _bed_cooldown_tasks[printer_id] = task + if not archive_id: logger.warning("Could not find archive for print complete: filename=%s, subtask=%s", filename, subtask_name) return @@ -2791,86 +2873,6 @@ async def on_print_complete(printer_id: int, data: dict): asyncio.create_task(_background_layer_timelapse()) - # Start bed cooldown monitor (polls bed temp until it drops below threshold) - async def _background_bed_cooldown(): - """Monitor bed temperature after print and notify when cooled.""" - try: - from backend.app.api.routes.settings import get_setting - - # Check threshold setting - async with async_session() as db: - threshold_str = await get_setting(db, "bed_cooled_threshold") - threshold = float(threshold_str) if threshold_str else 35.0 - - # Check if any provider has on_bed_cooled enabled (early exit if none) - async with async_session() as db: - providers = await notification_service._get_providers_for_event(db, "on_bed_cooled", printer_id) - if not providers: - logger.debug("[BED-COOL] No providers enabled for bed_cooled on printer %s", printer_id) - return - - logger.info("[BED-COOL] Monitoring bed temp for printer %s (threshold: %.0f°C)", printer_id, threshold) - - max_polls = 120 # 120 * 15s = 30 min timeout - for _ in range(max_polls): - await asyncio.sleep(15) - - # Check if printer is still connected - status = printer_manager.get_status(printer_id) - if status is None: - logger.info("[BED-COOL] Printer %s disconnected, stopping monitor", printer_id) - return - - # Check if a new print started (state == RUNNING) - if hasattr(status, "state") and status.state == "RUNNING": - logger.info("[BED-COOL] New print started on printer %s, stopping monitor", printer_id) - return - - # Get bed temperature - bed_temp = None - if hasattr(status, "temperatures") and status.temperatures: - bed_temp = status.temperatures.get("bed") - - if bed_temp is None: - continue - - if bed_temp <= threshold: - logger.info( - "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)", - bed_temp, - printer_id, - threshold, - ) - printer_info = printer_manager.get_printer(printer_id) - p_name = printer_info.name if printer_info else "Unknown" - async with async_session() as db: - await notification_service.on_bed_cooled( - printer_id=printer_id, - printer_name=p_name, - bed_temp=bed_temp, - threshold=threshold, - filename=filename or subtask_name or "", - db=db, - ) - return - - logger.info("[BED-COOL] Timeout waiting for bed to cool on printer %s", printer_id) - except asyncio.CancelledError: - logger.info("[BED-COOL] Bed cooldown monitor cancelled for printer %s", printer_id) - except Exception as e: - logger.warning("[BED-COOL] Failed: %s", e) - finally: - _bed_cooldown_tasks.pop(printer_id, None) - - # Only start bed cooldown for completed prints - if data.get("status") == "completed": - # Cancel any existing task for this printer - existing_task = _bed_cooldown_tasks.pop(printer_id, None) - if existing_task and not existing_task.done(): - existing_task.cancel() - task = asyncio.create_task(_background_bed_cooldown()) - _bed_cooldown_tasks[printer_id] = task - log_timing("All background tasks scheduled") # Auto-scan for timelapse if recording was active during the print diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 43038a4fd..9d49e5cfa 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -50,6 +50,8 @@ def event_loop(): """Create an instance of the default event loop for each test session.""" loop = asyncio.get_event_loop_policy().new_event_loop() yield loop + # Drain pending callbacks so aiosqlite threads can finish before loop closes + loop.run_until_complete(asyncio.sleep(0.05)) loop.close() @@ -89,6 +91,10 @@ async def test_engine(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await engine.dispose() + # Allow aiosqlite's background thread to finish processing the close + # response before the per-function event loop shuts down, preventing + # "RuntimeError: Event loop is closed" in call_soon_threadsafe. + await asyncio.sleep(0.01) @pytest.fixture