diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ec0433b..f9d1b7f87 100644 --- a/CHANGELOG.md +++ b/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. ### 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). - **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. diff --git a/backend/app/main.py b/backend/app/main.py index 10100e55a..3b40a5685 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -281,8 +281,10 @@ _HMS_CLEAR_GRACE_SECONDS = 30.0 # Used for snapshot-diff detection at print completion _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. # 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 _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 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") - # 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 # 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: 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) + # Check if any provider has on_bed_cooled enabled (skip registration 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) - - # 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: - 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 # 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) + # 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 async with async_session() as db: from backend.app.api.routes.settings import get_setting diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py index cfe0c79de..83abc805e 100644 --- a/backend/app/services/bambu_mqtt.py +++ b/backend/app/services/bambu_mqtt.py @@ -283,6 +283,7 @@ class BambuMQTTClient: on_print_complete: Callable[[dict], None] | None = None, on_ams_change: Callable[[list], 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.serial_number = serial_number @@ -293,6 +294,7 @@ class BambuMQTTClient: self.on_print_complete = on_print_complete self.on_ams_change = on_ams_change self.on_layer_change = on_layer_change + self.on_bed_temp_update = on_bed_temp_update self.state = PrinterState() self._client: mqtt.Client | None = None @@ -2106,6 +2108,10 @@ class BambuMQTTClient: for key, value in temps.items(): 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 # Priority: local target (if recent) > explicit target (chamber_target) > 0 if "chamber" in temps and "chamber_heating" not in temps: diff --git a/backend/app/services/printer_manager.py b/backend/app/services/printer_manager.py index d70a1075a..90bdd7c44 100644 --- a/backend/app/services/printer_manager.py +++ b/backend/app/services/printer_manager.py @@ -150,6 +150,7 @@ class PrinterManager: 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 + self._on_bed_temp_update: Callable[[int, float], None] | None = None self._loop: asyncio.AbstractEventLoop | None = None # Track who started the current print (Issue #206) 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).""" 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): """Schedule an async coroutine from a sync context. @@ -255,6 +260,10 @@ class PrinterManager: if self._on_layer_change: 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( ip_address=printer.ip_address, serial_number=printer.serial_number, @@ -265,6 +274,7 @@ class PrinterManager: on_print_complete=on_print_complete, on_ams_change=on_ams_change, on_layer_change=on_layer_change, + on_bed_temp_update=on_bed_temp_update, ) client.connect()