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.
This commit is contained in:
maziggy
2026-04-02 10:37:48 +02:00
parent cb0c4b5ffe
commit 34ccc7b6e7
4 changed files with 77 additions and 93 deletions
+1
View File
@@ -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.
+60 -93
View File
@@ -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
+6
View File
@@ -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:
+10
View File
@@ -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()