Browse Source

Fix bed cooled notification never firing on some firmware (#872)

  Replace polling-based bed cooldown monitor with event-driven approach.
  Some firmware stops sending bed_temper after print completion, causing
  the 30-min polling loop to time out on stale cached values. Now reacts
  instantly to bed_temper MQTT data via a new on_bed_temp_update callback.
maziggy 1 month ago
parent
commit
34ccc7b6e7
4 changed files with 77 additions and 93 deletions
  1. 1 0
      CHANGELOG.md
  2. 60 93
      backend/app/main.py
  3. 6 0
      backend/app/services/bambu_mqtt.py
  4. 10 0
      backend/app/services/printer_manager.py

+ 1 - 0
CHANGELOG.md

@@ -23,6 +23,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Developer Mode Detection for A1/P1 Printers** — Printers that don't send the `fun` field in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (`mqtt message verify failed`). Printers that do send the `fun` field (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.
 - **Developer Mode Detection for A1/P1 Printers** — Printers that don't send the `fun` field in MQTT status (A1, P1 series) now have developer mode detected via a probe command. After receiving the first full status update, Bambuddy sends a no-op external slot configure and checks whether the printer accepts or rejects it (`mqtt message verify failed`). Printers that do send the `fun` field (X1C, H2D, etc.) continue to use the existing bit-based detection. Developer mode state is re-checked on every reconnect.
 
 
 ### Fixed
 ### Fixed
+- **Bed Cooled Notification Never Firing** ([#872](https://github.com/maziggy/bambuddy/issues/872)) — Replaced the polling-based bed cooldown monitor with an event-driven approach. The old implementation polled cached bed temperature every 15 seconds for up to 30 minutes after print completion, but some printer firmware (e.g. P2S 01.00.05.00) stops including `bed_temper` in MQTT updates after a print finishes — even in response to pushall requests — causing the cached value to stay frozen at the end-of-print temperature until the monitor timed out. The new approach registers a waiter at print completion and reacts instantly when `bed_temper` data arrives via MQTT, whenever that may be. No timeout, no polling, no stale data — the notification fires as soon as the printer reports the bed is at or below the configured threshold.
 - **Filament Color and Subtype Inconsistencies** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like "Dark Gray" instead of Bambu-specific names like "Titan Gray" because the fallback skipped the Bambu hex color database. (2) "Silk+" subtype was missing from the known variants list, so the Edit Spool dropdown showed "Silk" instead. Also added "Tough+". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as "Basic" and PLA Silk Dual Color as "Silk" because the firmware only sends the base material in `tray_sub_brands`. Now detects gradient/multi-color/tri-color variants from the `tray_id_name` color code pattern (M\*/T\* suffixes).
 - **Filament Color and Subtype Inconsistencies** ([#857](https://github.com/maziggy/bambuddy/issues/857)) — Fixed several filament identification issues: (1) AMS slot popup showed generic color names like "Dark Gray" instead of Bambu-specific names like "Titan Gray" because the fallback skipped the Bambu hex color database. (2) "Silk+" subtype was missing from the known variants list, so the Edit Spool dropdown showed "Silk" instead. Also added "Tough+". (3) Gradient and Dual Color filaments were misclassified — PLA Basic Gradient was detected as "Basic" and PLA Silk Dual Color as "Silk" because the firmware only sends the base material in `tray_sub_brands`. Now detects gradient/multi-color/tri-color variants from the `tray_id_name` color code pattern (M\*/T\* suffixes).
 - **External Spool Print Fails on Printers With AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854), [#859](https://github.com/maziggy/bambuddy/issues/859)) — Two related issues with external spool printing: (1) Sending a print to a printer with no AMS units and only an external spool caused "Failed to get AMS mapping table" because the command was sent with `use_ams: true`. Now automatically sets `use_ams: false` when all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C with `ams_exist_bits=1, tray_exist_bits=0`) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command used `ams_id: 254` in `ams_mapping2` instead of `255`. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio uses `ams_id: 255` (VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool to `ams_id: 255` on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.
 - **External Spool Print Fails on Printers With AMS** ([#854](https://github.com/maziggy/bambuddy/issues/854), [#859](https://github.com/maziggy/bambuddy/issues/859)) — Two related issues with external spool printing: (1) Sending a print to a printer with no AMS units and only an external spool caused "Failed to get AMS mapping table" because the command was sent with `use_ams: true`. Now automatically sets `use_ams: false` when all filament slots map to external spools. (2) Printers with an AMS connected but empty (e.g. X1C with `ams_exist_bits=1, tray_exist_bits=0`) got stuck at heatbed heating or hit the same 07FF_8012 error because the print command used `ams_id: 254` in `ams_mapping2` instead of `255`. The firmware interpreted 254 as a physical AMS tray target instead of external spool. BambuStudio uses `ams_id: 255` (VIRTUAL_TRAY_MAIN_ID) for single-nozzle external spool. Fixed by mapping external spool to `ams_id: 255` on all non-H2D printers. H2D dual-nozzle printers retain 254 (deputy) / 255 (main) distinction.
 - **External Folder Scan 500 Error on 3MF Files** ([#846](https://github.com/maziggy/bambuddy/issues/846)) — Scanning an external folder containing .3mf files crashed with "Object of type bytes is not JSON serializable". The parsed 3MF metadata contained raw thumbnail bytes (`_thumbnail_data`) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent `parser.extract_thumbnail()` method — thumbnail data is already available in the parsed metadata. Now uses the same `clean_metadata()` pattern as upload and zip extraction.
 - **External Folder Scan 500 Error on 3MF Files** ([#846](https://github.com/maziggy/bambuddy/issues/846)) — Scanning an external folder containing .3mf files crashed with "Object of type bytes is not JSON serializable". The parsed 3MF metadata contained raw thumbnail bytes (`_thumbnail_data`) that were stored directly in the database JSON column without cleaning. Also removed a call to the non-existent `parser.extract_thumbnail()` method — thumbnail data is already available in the parsed metadata. Now uses the same `clean_metadata()` pattern as upload and zip extraction.

+ 60 - 93
backend/app/main.py

@@ -281,8 +281,10 @@ _HMS_CLEAR_GRACE_SECONDS = 30.0
 # Used for snapshot-diff detection at print completion
 # Used for snapshot-diff detection at print completion
 _timelapse_baselines: dict[int, set[str]] = {}
 _timelapse_baselines: dict[int, set[str]] = {}
 
 
-# Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task}
-_bed_cooldown_tasks: dict[int, asyncio.Task] = {}
+# Track printers waiting for bed to cool after print completion.
+# Event-driven: fires when bed_temper arrives via MQTT below threshold.
+# {printer_id: {"threshold": float, "filename": str, "registered_at": float}}
+_bed_cool_waiters: dict[int, dict] = {}
 
 
 # Track printers where the user explicitly stopped the print from the queue UI.
 # Track printers where the user explicitly stopped the print from the queue UI.
 # When on_print_complete fires with status "failed" for these printers we treat it
 # When on_print_complete fires with status "failed" for these printers we treat it
@@ -1219,11 +1221,9 @@ async def on_print_start(printer_id: int, data: dict):
     # Clear any stale user-stopped flag from previous print cycles
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
     _user_stopped_printers.discard(printer_id)
 
 
-    # Cancel any active bed cooldown task for this printer
-    existing_task = _bed_cooldown_tasks.pop(printer_id, None)
-    if existing_task and not existing_task.done():
-        existing_task.cancel()
-        logger.info("[BED-COOL] Cancelled bed cooldown monitor for printer %s (new print started)", printer_id)
+    # 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)
 
 
     # Clear cached cover images so the new print's thumbnail is fetched fresh
     # Clear cached cover images so the new print's thumbnail is fetched fresh
     from backend.app.api.routes.printers import clear_cover_cache
     from backend.app.api.routes.printers import clear_cover_cache
@@ -2537,104 +2537,35 @@ async def on_print_complete(printer_id: int, data: dict):
 
 
     log_timing("Queue item update")
     log_timing("Queue item update")
 
 
-    # Start bed cooldown monitor (polls bed temp until it drops below threshold)
+    # Register bed cooldown waiter (event-driven via on_bed_temp_update callback).
     # Must run before archive_id early-return so it fires for all prints (including
     # Must run before archive_id early-return so it fires for all prints (including
     # prints started from BambuStudio/touchscreen that have no archive).
     # prints started from BambuStudio/touchscreen that have no archive).
-    async def _background_bed_cooldown():
-        """Monitor bed temperature after print and notify when cooled."""
+    if data.get("status") == "completed":
         try:
         try:
             from backend.app.api.routes.settings import get_setting
             from backend.app.api.routes.settings import get_setting
 
 
-            # Check threshold setting
             async with async_session() as db:
             async with async_session() as db:
                 threshold_str = await get_setting(db, "bed_cooled_threshold")
                 threshold_str = await get_setting(db, "bed_cooled_threshold")
             threshold = float(threshold_str) if threshold_str else 35.0
             threshold = float(threshold_str) if threshold_str else 35.0
 
 
-            # Check if any provider has on_bed_cooled enabled (early exit if none)
+            # Check if any provider has on_bed_cooled enabled (skip registration if none)
             async with async_session() as db:
             async with async_session() as db:
                 providers = await notification_service._get_providers_for_event(db, "on_bed_cooled", printer_id)
                 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)
-
-            # Request a fresh full status so we get current bed_temper
-            printer_manager.request_status_update(printer_id)
-
-            max_polls = 120  # 120 * 15s = 30 min timeout
-            for poll_num in range(max_polls):
-                await asyncio.sleep(15)
-
-                # Request fresh temperature data every 60s — after print completion,
-                # the printer may send partial MQTT updates without bed_temper,
-                # leaving the cached value stale at the end-of-print temperature.
-                if poll_num % 4 == 0:
-                    printer_manager.request_status_update(printer_id)
-
-                # 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:
-                    logger.debug(
-                        "[BED-COOL] Printer %s: bed temp is None (keys: %s, state: %s)",
-                        printer_id,
-                        list(status.temperatures.keys()) if isinstance(status.temperatures, dict) else "N/A",
-                        status.state if hasattr(status, "state") else "N/A",
-                    )
-                    continue
-
-                logger.debug("[BED-COOL] Printer %s: bed=%.1f°C, threshold=%.0f°C", printer_id, bed_temp, threshold)
-
-                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)
+            if providers:
+                _bed_cool_waiters[printer_id] = {
+                    "threshold": threshold,
+                    "filename": filename or subtask_name or "",
+                    "registered_at": time.time(),
+                }
+                logger.info(
+                    "[BED-COOL] Registered waiter for printer %s (threshold: %.0f°C)",
+                    printer_id,
+                    threshold,
+                )
+            else:
+                logger.debug("[BED-COOL] No providers enabled for bed_cooled on printer %s", printer_id)
         except Exception as e:
         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
+            logger.warning("[BED-COOL] Failed to register waiter: %s", e)
 
 
     # --- Track filament consumption (must run before archive_id early-return so usage
     # --- Track filament consumption (must run before archive_id early-return so usage
     # is recorded even when auto-archive is disabled) ---
     # is recorded even when auto-archive is disabled) ---
@@ -3761,6 +3692,42 @@ async def lifespan(app: FastAPI):
 
 
     printer_manager.set_layer_change_callback(on_layer_change)
     printer_manager.set_layer_change_callback(on_layer_change)
 
 
+    # Event-driven bed cooldown: fires whenever bed_temper arrives via MQTT
+    async def on_bed_temp_update(printer_id: int, bed_temp: float):
+        waiter = _bed_cool_waiters.get(printer_id)
+        if not waiter:
+            return
+        threshold = waiter["threshold"]
+        if bed_temp > threshold:
+            return
+        # Bed is at or below threshold — fire notification and remove waiter
+        waiter_info = _bed_cool_waiters.pop(printer_id, None)
+        if not waiter_info:
+            return  # Another callback already handled it
+        bed_cool_logger = logging.getLogger(__name__)
+        bed_cool_logger.info(
+            "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)",
+            bed_temp,
+            printer_id,
+            threshold,
+        )
+        try:
+            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=waiter_info["filename"],
+                    db=db,
+                )
+        except Exception as e:
+            bed_cool_logger.warning("[BED-COOL] Failed to send notification: %s", e)
+
+    printer_manager.set_bed_temp_update_callback(on_bed_temp_update)
+
     # Initialize MQTT relay from settings
     # Initialize MQTT relay from settings
     async with async_session() as db:
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
         from backend.app.api.routes.settings import get_setting

+ 6 - 0
backend/app/services/bambu_mqtt.py

@@ -283,6 +283,7 @@ class BambuMQTTClient:
         on_print_complete: Callable[[dict], None] | None = None,
         on_print_complete: Callable[[dict], None] | None = None,
         on_ams_change: Callable[[list], None] | None = None,
         on_ams_change: Callable[[list], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
+        on_bed_temp_update: Callable[[float], None] | None = None,
     ):
     ):
         self.ip_address = ip_address
         self.ip_address = ip_address
         self.serial_number = serial_number
         self.serial_number = serial_number
@@ -293,6 +294,7 @@ class BambuMQTTClient:
         self.on_print_complete = on_print_complete
         self.on_print_complete = on_print_complete
         self.on_ams_change = on_ams_change
         self.on_ams_change = on_ams_change
         self.on_layer_change = on_layer_change
         self.on_layer_change = on_layer_change
+        self.on_bed_temp_update = on_bed_temp_update
 
 
         self.state = PrinterState()
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
         self._client: mqtt.Client | None = None
@@ -2106,6 +2108,10 @@ class BambuMQTTClient:
             for key, value in temps.items():
             for key, value in temps.items():
                 self.state.temperatures[key] = value
                 self.state.temperatures[key] = value
 
 
+            # Notify bed temperature updates (used by event-driven bed cooldown monitor)
+            if "bed" in temps and self.on_bed_temp_update:
+                self.on_bed_temp_update(temps["bed"])
+
             # Calculate chamber_heating after all targets are known
             # Calculate chamber_heating after all targets are known
             # Priority: local target (if recent) > explicit target (chamber_target) > 0
             # Priority: local target (if recent) > explicit target (chamber_target) > 0
             if "chamber" in temps and "chamber_heating" not in temps:
             if "chamber" in temps and "chamber_heating" not in temps:

+ 10 - 0
backend/app/services/printer_manager.py

@@ -150,6 +150,7 @@ class PrinterManager:
         self._on_status_change: Callable[[int, PrinterState], 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_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
+        self._on_bed_temp_update: Callable[[int, float], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -208,6 +209,10 @@ class PrinterManager:
         """Set callback for layer change events. Receives (printer_id, layer_num)."""
         """Set callback for layer change events. Receives (printer_id, layer_num)."""
         self._on_layer_change = callback
         self._on_layer_change = callback
 
 
+    def set_bed_temp_update_callback(self, callback: Callable[[int, float], None]):
+        """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
+        self._on_bed_temp_update = callback
+
     def _schedule_async(self, coro):
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
         """Schedule an async coroutine from a sync context.
 
 
@@ -255,6 +260,10 @@ class PrinterManager:
             if self._on_layer_change:
             if self._on_layer_change:
                 self._schedule_async(self._on_layer_change(printer_id, layer_num))
                 self._schedule_async(self._on_layer_change(printer_id, layer_num))
 
 
+        def on_bed_temp_update(bed_temp: float):
+            if self._on_bed_temp_update:
+                self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
+
         client = BambuMQTTClient(
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
             serial_number=printer.serial_number,
@@ -265,6 +274,7 @@ class PrinterManager:
             on_print_complete=on_print_complete,
             on_print_complete=on_print_complete,
             on_ams_change=on_ams_change,
             on_ams_change=on_ams_change,
             on_layer_change=on_layer_change,
             on_layer_change=on_layer_change,
+            on_bed_temp_update=on_bed_temp_update,
         )
         )
 
 
         client.connect()
         client.connect()