diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py
index 7f4ad3ad5..efc289a68 100644
--- a/backend/app/api/routes/printers.py
+++ b/backend/app/api/routes/printers.py
@@ -1299,3 +1299,36 @@ async def refresh_ams_slot(
raise HTTPException(400, message)
return {"success": True, "message": message}
+
+
+@router.get("/{printer_id}/runtime-debug")
+async def get_runtime_debug(
+ printer_id: int,
+ db: AsyncSession = Depends(get_db),
+):
+ """Debug endpoint: Get runtime tracking status for a printer."""
+ result = await db.execute(select(Printer).where(Printer.id == printer_id))
+ printer = result.scalar_one_or_none()
+ if not printer:
+ raise HTTPException(404, "Printer not found")
+
+ state = printer_manager.get_status(printer_id)
+
+ return {
+ "printer_name": printer.name,
+ "runtime_seconds": printer.runtime_seconds,
+ "runtime_hours": printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0,
+ "print_hours_offset": printer.print_hours_offset,
+ "total_hours": (printer.runtime_seconds / 3600.0 if printer.runtime_seconds else 0)
+ + (printer.print_hours_offset or 0),
+ "last_runtime_update": printer.last_runtime_update.isoformat() if printer.last_runtime_update else None,
+ "mqtt_state": {
+ "connected": state.connected if state else False,
+ "state": state.state if state else None,
+ "progress": state.progress if state else None,
+ "gcode_file": state.gcode_file if state else None,
+ }
+ if state
+ else None,
+ "is_active": printer.is_active,
+ }
diff --git a/backend/app/main.py b/backend/app/main.py
index 6154637bf..4e6479898 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1597,7 +1597,11 @@ async def track_printer_runtime():
for printer in printers:
# Get current state from printer manager
state = printer_manager.get_status(printer.id)
- if not state or not state.connected:
+ if not state:
+ logger.debug(f"[{printer.name}] Runtime tracking: no state available")
+ continue
+ if not state.connected:
+ logger.debug(f"[{printer.name}] Runtime tracking: not connected")
continue
# Check if printer is in an active state (RUNNING or PAUSE)
@@ -1610,14 +1614,27 @@ async def track_printer_runtime():
printer.runtime_seconds += int(elapsed)
updated_count += 1
needs_commit = True
+ logger.debug(
+ f"[{printer.name}] Runtime tracking: added {int(elapsed)}s, "
+ f"total={printer.runtime_seconds}s ({printer.runtime_seconds/3600:.2f}h)"
+ )
+ else:
+ logger.warning(
+ f"[{printer.name}] Runtime tracking: skipped elapsed={elapsed:.1f}s "
+ f"(outside valid range 0-{RUNTIME_TRACKING_INTERVAL * 2}s)"
+ )
else:
# First time seeing printer active - need to commit to save timestamp
needs_commit = True
+ logger.debug(f"[{printer.name}] Runtime tracking: first active detection")
printer.last_runtime_update = now
else:
# Printer is idle/offline - clear last_runtime_update
if printer.last_runtime_update is not None:
+ logger.debug(
+ f"[{printer.name}] Runtime tracking: state={state.state}, clearing last_runtime_update"
+ )
printer.last_runtime_update = None
needs_commit = True
diff --git a/backend/app/services/bambu_mqtt.py b/backend/app/services/bambu_mqtt.py
index d0eac3705..55045a49d 100644
--- a/backend/app/services/bambu_mqtt.py
+++ b/backend/app/services/bambu_mqtt.py
@@ -862,11 +862,24 @@ class BambuMQTTClient:
logger.warning(f"[{self.serial_number}] Unexpected AMS data format: {type(ams_data)}")
return
- # Store AMS data in raw_data so it's accessible via API
- self.state.raw_data["ams"] = ams_list
+ # Merge AMS data instead of replacing, to handle partial updates
+ # During prints, the printer may only send updates for active AMS units
+ existing_ams = self.state.raw_data.get("ams", [])
+ existing_by_id = {ams.get("id"): ams for ams in existing_ams if ams.get("id") is not None}
+
+ # Update existing units with new data, add new units
+ for ams_unit in ams_list:
+ ams_id = ams_unit.get("id")
+ if ams_id is not None:
+ existing_by_id[ams_id] = ams_unit
+
+ # Convert back to list, sorted by ID for consistent ordering
+ merged_ams = sorted(existing_by_id.values(), key=lambda x: x.get("id", 0))
+ self.state.raw_data["ams"] = merged_ams
+
# Update timestamp for RFID refresh detection (frontend can detect "new data arrived")
self.state.last_ams_update = time.time()
- logger.debug(f"[{self.serial_number}] Stored AMS data with {len(ams_list)} units")
+ logger.debug(f"[{self.serial_number}] Merged AMS data: {len(ams_list)} new units, {len(merged_ams)} total")
# Extract ams_extruder_map from each AMS unit's info field
# According to OpenBambuAPI: info field bit 8 indicates which extruder (0=right, 1=left)
diff --git a/icons/5f21bc794a4e4521b72c6564029ed5d9.svg b/icons/5f21bc794a4e4521b72c6564029ed5d9.svg
new file mode 100644
index 000000000..9d0321d94
--- /dev/null
+++ b/icons/5f21bc794a4e4521b72c6564029ed5d9.svg
@@ -0,0 +1 @@
+
diff --git a/icons/fc3b0d1fbe8b4815b0f7dbd92f3b68fd.svg b/icons/fc3b0d1fbe8b4815b0f7dbd92f3b68fd.svg
new file mode 100644
index 000000000..9d0321d94
--- /dev/null
+++ b/icons/fc3b0d1fbe8b4815b0f7dbd92f3b68fd.svg
@@ -0,0 +1 @@
+