Browse Source

Merge branch '0.2.0b' into main

ZwF 3 months ago
parent
commit
d8a48bc0af
36 changed files with 1890 additions and 206 deletions
  1. 2 0
      .gitignore
  2. 12 1
      CHANGELOG.md
  3. 6 4
      README.md
  4. 7 0
      backend/app/api/routes/archives.py
  5. 7 0
      backend/app/api/routes/library.py
  6. 70 11
      backend/app/api/routes/maintenance.py
  7. 17 0
      backend/app/core/database.py
  8. 50 3
      backend/app/main.py
  9. 31 2
      backend/app/schemas/notification_template.py
  10. 32 6
      backend/app/services/archive.py
  11. 1 1
      backend/app/services/bambu_ftp.py
  12. 13 0
      backend/app/services/notification_service.py
  13. 19 0
      backend/app/services/print_scheduler.py
  14. 6 15
      backend/app/services/spoolman.py
  15. 111 30
      backend/app/services/usage_tracker.py
  16. 63 0
      backend/app/utils/printer_models.py
  17. 56 0
      backend/app/utils/threemf_tools.py
  18. 6 6
      backend/tests/unit/services/test_bambu_ftp.py
  19. 66 0
      backend/tests/unit/services/test_spoolman_service.py
  20. 27 14
      backend/tests/unit/services/test_usage_tracker.py
  21. 188 0
      backend/tests/unit/test_scheduler_ams_mapping.py
  22. 726 0
      backend/tests/unit/test_usage_tracker.py
  23. 179 0
      frontend/src/__tests__/hooks/useFilamentMapping.test.ts
  24. 13 2
      frontend/src/components/PrintModal/FilamentMapping.tsx
  25. 1 0
      frontend/src/components/PrintModal/types.ts
  26. 27 44
      frontend/src/hooks/useFilamentMapping.ts
  27. 26 14
      frontend/src/hooks/useMultiPrinterFilamentMapping.ts
  28. 10 0
      frontend/src/i18n/locales/de.ts
  29. 10 0
      frontend/src/i18n/locales/en.ts
  30. 10 0
      frontend/src/i18n/locales/it.ts
  31. 10 0
      frontend/src/i18n/locales/ja.ts
  32. 19 9
      frontend/src/pages/MaintenancePage.tsx
  33. 62 37
      frontend/src/pages/PrintersPage.tsx
  34. 0 0
      static/assets/index-DIy2OgxD.js
  35. 1 1
      static/index.html
  36. 6 6
      test_backend.sh

+ 2 - 0
.gitignore

@@ -28,6 +28,8 @@ npm-debug.log*
 # Database
 *.db
 *.db-journal
+*.db-wal
+*.db-shm
 
 # Archive files (user data)
 archive/

+ 12 - 1
CHANGELOG.md

@@ -7,9 +7,20 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 - **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
 - **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
-- **Spool Inventory — 3MF-Based Usage Tracking for Non-BL Spools** — Non-Bambu-Lab spools (no RFID) cannot use AMS remain% for usage tracking. Now falls back to per-filament weight estimates from the archived 3MF file (`used_g` per filament slot). For completed prints, uses the full slicer estimate. For failed or aborted prints, scales by print progress percentage. Bambu Lab spools continue using AMS remain% delta tracking as before.
+- **Spool Inventory — Unified 3MF-Based Usage Tracking** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament `used_g` data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue `ams_mapping` for queue-initiated prints and the printer's `tray_now` state for single-filament non-queue prints, ensuring the correct physical spool is always tracked.
+- **Notification Templates — Filament Usage Variables** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — `print_complete`, `print_failed`, and `print_stopped` notification events now expose `{filament_grams}` (total grams, scaled by progress for partial prints), `{filament_details}` (per-filament breakdown, e.g. "PLA: 10.0g | PETG: 5.0g"), and `{progress}` (completion percentage for failed/stopped prints). Webhook payloads include `filament_used`, `filament_details`, and `progress` fields. Per-slot filament data is stored in archive `extra_data` for downstream use.
+- **Printer Status Summary Bar — Next Available & Availability Count** ([#354](https://github.com/maziggy/bambuddy/issues/354)) — The status bar on the Printers page now shows an availability count ("X available") alongside the printing/offline counts, and a "Next available" indicator showing which printing printer will finish soonest — with printer name, mini progress bar, completion percentage, and remaining time. Useful for print farms to quickly identify the next free printer. Updates in real-time via WebSocket. Translated in all 4 locales (en, de, ja, it).
+- **Nozzle-Aware AMS Filament Mapping for Dual-Nozzle Printers** ([#318](https://github.com/maziggy/bambuddy/issues/318)) — On dual-nozzle printers (H2D, H2D Pro), each AMS unit is physically connected to either the left or right nozzle. Bambuddy now reads nozzle assignments from the 3MF file (`filament_nozzle_map` + `physical_extruder_map` in `project_settings.config`) and constrains filament matching to only AMS trays connected to the correct nozzle via `ams_extruder_map`. Applies to the print scheduler, reprint modal, queue modal, and multi-printer selection. Falls back gracefully to unfiltered matching when no trays exist on the target nozzle. The filament mapping UI shows L/R nozzle badges for dual-nozzle prints. Translated in all 4 locales (en, de, ja, it).
 
 ### Fixed
+- **Bulk Archive Delete Leaves Orphaned Database Records** — When bulk-deleting archives, the files were removed from disk before the database commit. If concurrent SQLite writes caused a lock timeout, the commit failed and rolled back — leaving database records pointing to deleted files (broken thumbnails, 404 errors). Fixed by deleting the database record first and only removing files after a successful commit.
+- **Model-Specific Maintenance Tasks for Carbon Rods vs Linear Rails** ([#351](https://github.com/maziggy/bambuddy/issues/351)) — Maintenance tasks "Clean Carbon Rods" and "Lubricate Linear Rails" were shown for all printers regardless of motion system. H2 and A1 series use linear rails (not carbon rods), and X1/P1/P2S series use carbon rods (not linear rails). Maintenance types are now classified by rod/rail type: "Lubricate Carbon Rods" and "Clean Carbon Rods" for X1/P1/P2S, "Lubricate Linear Rails" and "Clean Linear Rails" for A1/H2. Stale and duplicate system types are automatically cleaned up on startup. Includes model-specific wiki links and i18n keys for all 4 locales.
+- **AMS Slot Configuration Overwritten on Startup** — Bambuddy was resetting AMS slot filament presets on every startup and reconnection. The `on_ams_change` callback unconditionally unlinked Bambu Lab spool assignments on each MQTT push-all response, then re-assigned them by sending `ams_filament_setting` without a `setting_id`, which cleared the printer's filament preset. Now compares spool RFID identifiers (`tray_uuid` / `tag_uid`) before unlinking — if the same spool is still in the slot, the assignment is preserved and no `ams_filament_setting` command is sent.
+- **Bambu Lab Spool Detection False Positives** — The `is_bambu_lab_spool()` function (backend) and `isBambuLabSpool()` (frontend) incorrectly identified third-party spools as Bambu Lab spools when they used Bambu generic filament presets (e.g., "Generic PLA"). The `tray_info_idx` field (e.g., "GFA00") identifies the filament *type*, not the spool manufacturer — third-party spools using Bambu presets also have GF-prefixed values. Removed `tray_info_idx` from detection logic; now uses only hardware RFID identifiers (`tray_uuid` and `tag_uid`) which are physically embedded in genuine Bambu Lab spools.
+- **FTP Disconnect Raises EOFError When Server Dies** — `BambuFTPClient.disconnect()` only caught `OSError` and `ftplib.Error`, but `quit()` raises `EOFError` when the server has closed the connection mid-session. `EOFError` is not a subclass of either, so it propagated to callers. Now caught alongside the other exception types for clean best-effort disconnect.
+
+### Improved
+- **SQLite WAL Mode for Database Reliability** — Database now uses Write-Ahead Logging (WAL) mode with a 5-second busy timeout, reducing "database is locked" errors under concurrent access. WAL mode allows simultaneous reads during writes, improving responsiveness for multi-printer setups. Automatically enabled on startup.
 - **External Camera Not Used for Snapshot + Stream Dropping** ([#325](https://github.com/maziggy/bambuddy/issues/325)) — The snapshot endpoint (`/camera/snapshot`) always used the internal printer camera even when an external camera was configured. Now checks for external camera first, matching the existing stream endpoint behavior. Also fixed external MJPEG and RTSP streams silently dropping every ~60 seconds due to missing reconnect logic — the underlying stream generators exit on read timeout, and the caller now retries up to 3 times with a 2-second delay instead of ending the stream.
 - **H2C Nozzle Rack Text Unreadable on Light Filament Colors** ([#300](https://github.com/maziggy/bambuddy/issues/300)) — Nozzle rack slots use the loaded filament color as background, but white/light filaments made the white "0.4" text nearly invisible. Now uses a luminance check to switch to dark text on light backgrounds.
 - **File Downloads Show Generic Filenames** ([#334](https://github.com/maziggy/bambuddy/issues/334)) — Downloaded files with special characters in their names (spaces, umlauts, parentheses) were saved as generic `file_1`, `file_2` instead of the original filename. The `Content-Disposition` header parser now handles RFC 5987 percent-encoded filenames (`filename*=utf-8''...`) used by FastAPI for non-ASCII characters. Fix applied to all download endpoints (library files, archives, source files, F3D files, project exports, support bundles, printer files).

+ 6 - 4
README.md

@@ -72,7 +72,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Timelapse editor (trim, speed, music)
-- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support)
+- Re-print to any connected printer with AMS mapping (auto-match or manual slot selection, multi-plate support, nozzle-aware matching for dual-nozzle H2D/H2D Pro)
 - Plate thumbnail browsing for multi-plate archives (hover to navigate between plates)
 - Archive comparison (side-by-side diff)
 - Tag management (rename/delete across all archives)
@@ -140,15 +140,17 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Email, Pushover, ntfy
 - Custom webhooks
 - Quiet hours & daily digest
-- Customizable message templates
+- Customizable message templates with per-filament usage details
 - Print finish photo URL in notifications
+- Filament usage and progress in failed/cancelled print notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
 - Queue events (waiting, skipped, failed)
 
 ### 🧵 Spool Inventory
 - Built-in spool inventory with AMS slot assignment, usage tracking, and remaining weight management
-- Automatic filament consumption tracking: AMS RFID for Bambu Lab spools, 3MF estimates for third-party spools
+- Automatic filament consumption tracking: 3MF slicer estimates for all spools (primary), AMS remain% delta as fallback
+- Per-layer gcode accuracy for partial prints (failed/cancelled), with linear scaling fallback
 - Spool catalog, color catalog, PA profile matching, and low-stock alerts
 
 ### 🔧 Integrations
@@ -200,7 +202,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 </tr>
 </table>
 
-**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE) • Auto updates • Database backup/restore • System info dashboard
+**Plus:** Configurable slicer (Bambu Studio / OrcaSlicer) • Customizable themes (style, background, accent) • Mobile responsive • Keyboard shortcuts • Multi-language (EN/DE/JA/IT) • Auto updates • Database backup/restore • System info dashboard
 
 ---
 

+ 7 - 0
backend/app/api/routes/archives.py

@@ -21,6 +21,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveStats, ArchiveUpdate, ReprintRequest
 from backend.app.services.archive import ArchiveService
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -2669,6 +2670,12 @@ async def get_filament_requirements(
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
+            # Enrich with nozzle mapping for dual-nozzle printers
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from archive %s: %s", archive_id, e)
 

+ 7 - 0
backend/app/api/routes/library.py

@@ -56,6 +56,7 @@ from backend.app.schemas.library import (
 )
 from backend.app.services.archive import ArchiveService, ThreeMFParser
 from backend.app.services.stl_thumbnail import generate_stl_thumbnail
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -1711,6 +1712,12 @@ async def get_library_file_filament_requirements(
             # Sort by slot ID
             filaments.sort(key=lambda x: x["slot_id"])
 
+            # Enrich with nozzle mapping for dual-nozzle printers
+            nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+            if nozzle_mapping:
+                for filament in filaments:
+                    filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
+
     except Exception as e:
         logger.warning("Failed to parse filament requirements from library file %s: %s", file_id, e)
 

+ 70 - 11
backend/app/api/routes/maintenance.py

@@ -26,6 +26,7 @@ from backend.app.schemas.maintenance import (
     PrinterMaintenanceUpdate,
 )
 from backend.app.services.notification_service import notification_service
+from backend.app.utils.printer_models import get_rod_type
 
 logger = logging.getLogger(__name__)
 
@@ -33,12 +34,33 @@ router = APIRouter(prefix="/maintenance", tags=["maintenance"])
 
 # Default maintenance types
 DEFAULT_MAINTENANCE_TYPES = [
+    # Carbon rod models only (X1/P1/P2S)
+    {
+        "name": "Lubricate Carbon Rods",
+        "description": "Apply lubricant to carbon rods for smooth motion",
+        "default_interval_hours": 50.0,
+        "icon": "Droplet",
+    },
+    {
+        "name": "Clean Carbon Rods",
+        "description": "Wipe carbon rods with a dry cloth",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    # Linear rail models only (A1/H2)
     {
         "name": "Lubricate Linear Rails",
-        "description": "Apply lubricant to linear rails and rods for smooth motion",
+        "description": "Apply lubricant to linear rails for smooth motion",
         "default_interval_hours": 50.0,
         "icon": "Droplet",
     },
+    {
+        "name": "Clean Linear Rails",
+        "description": "Wipe linear rails with a dry cloth to remove dust and debris",
+        "default_interval_hours": 100.0,
+        "icon": "Sparkles",
+    },
+    # Universal (all models)
     {
         "name": "Clean Nozzle/Hotend",
         "description": "Clean nozzle exterior and perform cold pull if needed",
@@ -51,12 +73,6 @@ DEFAULT_MAINTENANCE_TYPES = [
         "default_interval_hours": 200.0,
         "icon": "Ruler",
     },
-    {
-        "name": "Clean Carbon Rods",
-        "description": "Wipe carbon rods with a dry cloth",
-        "default_interval_hours": 100.0,
-        "icon": "Sparkles",
-    },
     {
         "name": "Clean Build Plate",
         "description": "Deep clean build plate with IPA or soap",
@@ -71,6 +87,30 @@ DEFAULT_MAINTENANCE_TYPES = [
     },
 ]
 
+# System types that only apply to printers with a specific rod/rail type.
+# "carbon" = X1/P1/P2S series (carbon rods), "linear_rail" = A1/H2 series.
+# Types not listed here apply to all printers.
+_ROD_TYPE_REQUIREMENTS: dict[str, str] = {
+    "Lubricate Carbon Rods": "carbon",
+    "Clean Carbon Rods": "carbon",
+    "Lubricate Linear Rails": "linear_rail",
+    "Clean Linear Rails": "linear_rail",
+}
+
+
+def _should_apply_to_printer(type_name: str, printer_model: str | None) -> bool:
+    """Check if a system maintenance type should apply to a given printer model."""
+    rod_requirement = _ROD_TYPE_REQUIREMENTS.get(type_name)
+    if rod_requirement is None:
+        return True  # Not model-specific, applies to all
+
+    rod_type = get_rod_type(printer_model)
+    if rod_type is None:
+        # Unknown model — default to carbon rods (legacy behavior)
+        return rod_requirement == "carbon"
+
+    return rod_type == rod_requirement
+
 
 async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
     """Calculate total active hours for a printer from runtime counter plus offset.
@@ -94,13 +134,27 @@ async def get_printer_total_hours(db: AsyncSession, printer_id: int) -> float:
 
 
 async def ensure_default_types(db: AsyncSession) -> None:
-    """Ensure default maintenance types exist."""
-    result = await db.execute(select(MaintenanceType).where(MaintenanceType.is_system.is_(True)))
+    """Ensure default maintenance types exist, remove stale/duplicate ones."""
+    result = await db.execute(
+        select(MaintenanceType).where(MaintenanceType.is_system.is_(True)).order_by(MaintenanceType.id)
+    )
     existing = result.scalars().all()
-    existing_names = {t.name for t in existing}
 
+    default_names = {t["name"] for t in DEFAULT_MAINTENANCE_TYPES}
+
+    # Remove stale system types no longer in defaults (e.g. renamed types)
+    # and deduplicate: if concurrent requests created the same type twice,
+    # keep only the first (lowest id) and delete the rest.
+    seen_names: set[str] = set()
+    for t in existing:
+        if t.name not in default_names or t.name in seen_names:
+            await db.delete(t)
+        else:
+            seen_names.add(t.name)
+
+    # Create any missing default types
     for type_def in DEFAULT_MAINTENANCE_TYPES:
-        if type_def["name"] not in existing_names:
+        if type_def["name"] not in seen_names:
             new_type = MaintenanceType(
                 name=type_def["name"],
                 description=type_def["description"],
@@ -228,6 +282,11 @@ async def _get_printer_maintenance_internal(
     now = datetime.utcnow()
 
     for maint_type in all_types:
+        # Skip system types that don't apply to this printer model
+        # (e.g., "Clean Carbon Rods" for H2D which has steel rods)
+        if maint_type.is_system and not _should_apply_to_printer(maint_type.name, printer.model):
+            continue
+
         item = existing_items.get(maint_type.id)
         default_interval_type = getattr(maint_type, "interval_type", "hours") or "hours"
 

+ 17 - 0
backend/app/core/database.py

@@ -1,14 +1,30 @@
+from sqlalchemy import event
 from sqlalchemy.exc import OperationalError
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import DeclarativeBase
 
 from backend.app.core.config import settings
 
+
+def _set_sqlite_pragmas(dbapi_conn, connection_record):
+    """Set SQLite pragmas on each new connection for concurrency and performance."""
+    cursor = dbapi_conn.cursor()
+    # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
+    cursor.execute("PRAGMA journal_mode = WAL")
+    # Wait up to 5 seconds when the database is locked instead of failing immediately
+    cursor.execute("PRAGMA busy_timeout = 5000")
+    cursor.execute("PRAGMA synchronous = NORMAL")
+    cursor.close()
+
+
 engine = create_async_engine(
     settings.database_url,
     echo=settings.debug,
 )
 
+# Register the pragma listener on the underlying sync engine
+event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
+
 async_session = async_sessionmaker(
     engine,
     class_=AsyncSession,
@@ -29,6 +45,7 @@ async def reinitialize_database():
         settings.database_url,
         echo=settings.debug,
     )
+    event.listen(engine.sync_engine, "connect", _set_sqlite_pragmas)
     async_session = async_sessionmaker(
         engine,
         class_=AsyncSession,

+ 50 - 3
backend/app/main.py

@@ -551,13 +551,45 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     )
                     stale.append(assignment)  # Slot empty
                 elif _is_bambu_uuid(current_tray.get("tray_uuid", "")):
-                    # A Bambu Lab spool was inserted — always unlink manual assignments
+                    # A Bambu Lab spool is in this slot — check if it's the same spool
+                    # that's currently assigned. If yes, keep the assignment (avoids
+                    # unnecessary unlink/re-assign/ams_filament_setting cycle that clears
+                    # the printer's filament preset on every startup).
+                    tray_uuid = current_tray.get("tray_uuid", "")
+                    tag_uid = current_tray.get("tag_uid", "")
+                    spool = assignment.spool
+                    spool_matches = False
+                    if spool:
+                        if (spool.tray_uuid and spool.tray_uuid.upper() == tray_uuid.upper()) or (
+                            spool.tag_uid
+                            and tag_uid
+                            and tag_uid != "0000000000000000"
+                            and spool.tag_uid.upper() == tag_uid.upper()
+                        ):
+                            spool_matches = True
+                    if spool_matches:
+                        # Same BL spool still in slot — keep assignment, update fingerprint if needed
+                        cur_color = current_tray.get("tray_color", "")
+                        cur_type = current_tray.get("tray_type", "")
+                        fp_color = assignment.fingerprint_color or ""
+                        fp_type = assignment.fingerprint_type or ""
+                        if cur_color.upper() != fp_color.upper() or cur_type.upper() != fp_type.upper():
+                            assignment.fingerprint_color = cur_color
+                            assignment.fingerprint_type = cur_type
+                            logger.debug(
+                                "Auto-unlink: spool %d AMS%d-T%d — same BL spool, updated fingerprint",
+                                assignment.spool_id,
+                                assignment.ams_id,
+                                assignment.tray_id,
+                            )
+                        continue
+                    # Different BL spool or unrecognized — unlink so auto-assign can match
                     logger.info(
-                        "Auto-unlink: spool %d AMS%d-T%d — Bambu Lab spool detected (uuid=%s)",
+                        "Auto-unlink: spool %d AMS%d-T%d — different Bambu Lab spool detected (uuid=%s)",
                         assignment.spool_id,
                         assignment.ams_id,
                         assignment.tray_id,
-                        current_tray.get("tray_uuid", ""),
+                        tray_uuid,
                     )
                     stale.append(assignment)
                 else:
@@ -2316,6 +2348,21 @@ async def on_print_complete(printer_id: int, data: dict):
                             "actual_filament_grams": archive.filament_used_grams,
                             "failure_reason": archive.failure_reason,
                         }
+
+                        # Scale filament usage for partial prints
+                        if print_status != "completed" and archive.filament_used_grams:
+                            progress = data.get("progress") or 0
+                            scale = max(0.0, min(progress / 100.0, 1.0))
+                            archive_data["actual_filament_grams"] = round(archive.filament_used_grams * scale, 1)
+                            archive_data["progress"] = progress
+
+                        # Pass per-slot data from archive.extra_data
+                        if archive.extra_data and archive.extra_data.get("filament_slots"):
+                            slots = archive.extra_data["filament_slots"]
+                            if print_status != "completed":
+                                scale = max(0.0, min((data.get("progress") or 0) / 100.0, 1.0))
+                                slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
+                            archive_data["filament_slots"] = slots
                         # Add finish photo URL and image bytes if available
                         if finish_photo_filename:
                             from backend.app.api.routes.settings import get_setting

+ 31 - 2
backend/app/schemas/notification_template.py

@@ -31,12 +31,34 @@ EVENT_VARIABLES: dict[str, list[str]] = {
         "filename",
         "duration",
         "filament_grams",
+        "filament_details",
+        "finish_photo_url",
+        "timestamp",
+        "app_name",
+    ],
+    "print_failed": [
+        "printer",
+        "filename",
+        "duration",
+        "filament_grams",
+        "filament_details",
+        "progress",
+        "reason",
+        "finish_photo_url",
+        "timestamp",
+        "app_name",
+    ],
+    "print_stopped": [
+        "printer",
+        "filename",
+        "duration",
+        "filament_grams",
+        "filament_details",
+        "progress",
         "finish_photo_url",
         "timestamp",
         "app_name",
     ],
-    "print_failed": ["printer", "filename", "duration", "reason", "finish_photo_url", "timestamp", "app_name"],
-    "print_stopped": ["printer", "filename", "duration", "finish_photo_url", "timestamp", "app_name"],
     "print_progress": ["printer", "filename", "progress", "remaining_time", "timestamp", "app_name"],
     "printer_offline": ["printer", "timestamp", "app_name"],
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
@@ -72,6 +94,7 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "filename": "Benchy.3mf",
         "duration": "1h 18m",
         "filament_grams": "15.2",
+        "filament_details": "PLA: 15.2g",
         "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_154800_abc12345.jpg",
         "timestamp": "2024-01-15 15:48",
         "app_name": "Bambuddy",
@@ -80,6 +103,9 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 45m",
+        "filament_grams": "7.6",
+        "filament_details": "PLA: 7.6g",
+        "progress": "50",
         "reason": "Filament runout",
         "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_151500_def67890.jpg",
         "timestamp": "2024-01-15 15:15",
@@ -89,6 +115,9 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "printer": "Bambu X1C",
         "filename": "Benchy.3mf",
         "duration": "0h 30m",
+        "filament_grams": "4.6",
+        "filament_details": "PLA: 4.6g",
+        "progress": "30",
         "finish_photo_url": "/api/v1/archives/123/photos/finish_20240115_150000_ghi11223.jpg",
         "timestamp": "2024-01-15 15:00",
         "app_name": "Bambuddy",

+ 32 - 6
backend/app/services/archive.py

@@ -151,6 +151,27 @@ class ThreeMFParser:
                         self.metadata["_slice_filament_type"] = ", ".join(types)
                     if colors:
                         self.metadata["_slice_filament_color"] = ",".join(colors)
+
+                    # Collect per-slot filament usage for tracking & notifications
+                    filament_slots = []
+                    for f in filaments:
+                        slot_id = f.get("id")
+                        used_g_str = f.get("used_g", "0")
+                        try:
+                            used_g = float(used_g_str)
+                        except (ValueError, TypeError):
+                            used_g = 0
+                        if used_g > 0 and slot_id:
+                            filament_slots.append(
+                                {
+                                    "slot_id": int(slot_id),
+                                    "used_g": round(used_g, 2),
+                                    "type": f.get("type", ""),
+                                    "color": f.get("color", ""),
+                                }
+                            )
+                    if filament_slots:
+                        self.metadata["filament_slots"] = filament_slots
         except Exception:
             pass  # Skip unparseable slice_info metadata
 
@@ -1026,8 +1047,9 @@ class ArchiveService:
         if not archive:
             return False
 
-        # Delete files - with CRITICAL safety checks to prevent accidental deletion
-        # of parent directories (e.g., /opt) if file_path is empty/malformed
+        # Resolve the directory to delete BEFORE committing the DB change
+        dir_to_delete: Path | None = None
+
         if archive.file_path and archive.file_path.strip():
             file_path = settings.base_dir / archive.file_path
             if file_path.exists():
@@ -1041,13 +1063,11 @@ class ArchiveService:
                         f"SECURITY: Refusing to delete archive {archive_id} - "
                         f"path {archive_dir} is outside archive directory {settings.archive_dir}"
                     )
-                    # Still delete the database record, just not the files
                     await self.db.delete(archive)
                     await self.db.commit()
                     return True
 
                 # Safety check 2: archive_dir must be at least 1 level deep inside archive_dir
-                # (should be archive_dir/uuid/file.3mf, so parent should be archive_dir/uuid)
                 try:
                     relative_path = archive_dir.resolve().relative_to(settings.archive_dir.resolve())
                     if len(relative_path.parts) < 1:
@@ -1061,16 +1081,22 @@ class ArchiveService:
                 except ValueError:
                     pass  # Already handled above
 
-                shutil.rmtree(archive_dir, ignore_errors=True)
+                dir_to_delete = archive_dir
         else:
             logger.error(
                 f"SECURITY: Refusing to delete files for archive {archive_id} - "
                 f"file_path is empty or invalid: '{archive.file_path}'"
             )
 
-        # Delete database record
+        # Delete database record FIRST — if the commit fails (e.g. database locked
+        # during concurrent bulk deletes), the files stay on disk and nothing is lost.
         await self.db.delete(archive)
         await self.db.commit()
+
+        # Only delete files AFTER the DB commit succeeds to avoid orphaned records
+        if dir_to_delete:
+            shutil.rmtree(dir_to_delete, ignore_errors=True)
+
         return True
 
     async def attach_timelapse(

+ 1 - 1
backend/app/services/bambu_ftp.py

@@ -181,7 +181,7 @@ class BambuFTPClient:
         if self._ftp:
             try:
                 self._ftp.quit()
-            except (OSError, ftplib.Error):
+            except (OSError, ftplib.Error, EOFError):
                 pass  # Best-effort FTP cleanup; connection may already be closed
             self._ftp = None
 

+ 13 - 0
backend/app/services/notification_service.py

@@ -728,6 +728,19 @@ class NotificationService:
             if archive_data.get("finish_photo_url"):
                 variables["finish_photo_url"] = archive_data["finish_photo_url"]
 
+            # Build per-slot breakdown string
+            if archive_data.get("filament_slots"):
+                parts = []
+                for slot in archive_data["filament_slots"]:
+                    ftype = slot.get("type", "Unknown") or "Unknown"
+                    used = slot.get("used_g", 0)
+                    parts.append(f"{ftype}: {used:.1f}g")
+                variables["filament_details"] = " | ".join(parts)
+
+            # Add progress for partial prints
+            if archive_data.get("progress") is not None:
+                variables["progress"] = str(archive_data["progress"])
+
         # Extract image data for providers that support attachments (e.g. Pushover)
         image_data = None
         if archive_data:

+ 19 - 0
backend/app/services/print_scheduler.py

@@ -23,6 +23,7 @@ from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.printer_models import normalize_printer_model
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 logger = logging.getLogger(__name__)
 
@@ -477,6 +478,12 @@ class PrintScheduler:
                             pass  # Skip filament entry with unparseable usage data
 
                 filaments.sort(key=lambda x: x["slot_id"])
+
+                # Enrich with nozzle mapping for dual-nozzle printers
+                nozzle_mapping = extract_nozzle_mapping_from_3mf(zf)
+                if nozzle_mapping:
+                    for filament in filaments:
+                        filament["nozzle_id"] = nozzle_mapping.get(filament["slot_id"])
         except Exception as e:
             logger.warning("Failed to parse filament requirements: %s", e)
             return None
@@ -494,6 +501,9 @@ class PrintScheduler:
         """
         filaments = []
 
+        # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
+        ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
+
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
@@ -524,6 +534,7 @@ class PrintScheduler:
                             "is_ht": is_ht,
                             "is_external": False,
                             "global_tray_id": global_tray_id,
+                            "extruder_id": ams_extruder_map.get(str(ams_id)),
                         }
                     )
 
@@ -541,6 +552,7 @@ class PrintScheduler:
                     "is_ht": False,
                     "is_external": True,
                     "global_tray_id": 254,
+                    "extruder_id": 0 if ams_extruder_map else None,
                 }
             )
 
@@ -616,6 +628,13 @@ class PrintScheduler:
             # Get available trays (not already used)
             available = [f for f in loaded if f["global_tray_id"] not in used_tray_ids]
 
+            # Nozzle-aware filtering: restrict to trays on the correct nozzle
+            req_nozzle_id = req.get("nozzle_id")
+            if req_nozzle_id is not None:
+                nozzle_filtered = [f for f in available if f.get("extruder_id") == req_nozzle_id]
+                if nozzle_filtered:
+                    available = nozzle_filtered
+
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
                 idx_matches = [f for f in available if f.get("tray_info_idx") == req_tray_info_idx]

+ 6 - 15
backend/app/services/spoolman.py

@@ -693,31 +693,22 @@ class SpoolmanClient:
     def is_bambu_lab_spool(self, tray_uuid: str, tag_uid: str = "", tray_info_idx: str = "") -> bool:
         """Check if a tray has a valid Bambu Lab spool.
 
-        Bambu Lab spools can be identified by:
+        Bambu Lab spools are identified by hardware RFID identifiers only:
         1. tray_uuid: 32-character hex string (preferred, consistent across printers)
         2. tag_uid: 16-character hex string (RFID tag, varies between readers)
-        3. tray_info_idx: Bambu filament preset ID like "GFA00" (most reliable)
 
-        Non-Bambu Lab spools (SpoolEase, third-party) won't have these identifiers.
+        Note: tray_info_idx (e.g. "GFA00") is NOT a reliable indicator — third-party
+        spools using Bambu generic presets also have GF-prefixed tray_info_idx values.
+        The tray_info_idx parameter is kept for API compatibility but ignored.
 
         Args:
             tray_uuid: The tray UUID to check (32 hex chars)
             tag_uid: The RFID tag UID to check as fallback (16 hex chars)
-            tray_info_idx: Bambu filament preset ID like "GFA00", "GFB00"
+            tray_info_idx: Ignored (kept for API compatibility)
 
         Returns:
-            True if the spool has valid Bambu Lab identifiers, False otherwise.
+            True if the spool has valid Bambu Lab RFID identifiers, False otherwise.
         """
-        # Check tray_info_idx first - Bambu filament preset IDs like "GFA00", "GFB00", etc.
-        # This is the most reliable indicator as it's set when the spool is recognized
-        if tray_info_idx:
-            idx = tray_info_idx.strip()
-            # Bambu Lab preset IDs start with "GF" followed by letter and digits
-            # e.g., GFA00, GFB00, GFL00, GFN00, GFG00, GFS00, GFU00
-            if idx and len(idx) >= 3 and idx.startswith("GF"):
-                logger.debug("Identified Bambu Lab spool via tray_info_idx: %s", idx)
-                return True
-
         # Check tray_uuid (preferred - consistent across printer models)
         if tray_uuid:
             uuid = tray_uuid.strip()

+ 111 - 30
backend/app/services/usage_tracker.py

@@ -3,10 +3,11 @@
 Captures AMS tray remain% at print start, then computes consumption
 deltas at print complete to update spool weight_used and last_used.
 
-For non-BL spools (no RFID, AMS reports remain=-1), falls back to
-per-filament usage estimates from the archived 3MF file.
+Primary tracking uses 3MF slicer estimates (precise per-filament data).
+AMS remain% delta is the fallback for trays not covered by 3MF data.
 """
 
+import json
 import logging
 from dataclasses import dataclass, field
 from datetime import datetime, timezone
@@ -87,9 +88,9 @@ async def on_print_complete(
 ) -> list[dict]:
     """Compute consumption deltas and update spool weight_used/last_used.
 
-    Uses two tracking strategies:
-    1. AMS remain% delta — for BL spools with valid RFID remain data
-    2. 3MF per-filament estimates — for non-BL spools without remain data
+    Uses two tracking strategies in priority order:
+    1. 3MF per-filament estimates (primary) — precise slicer data for all spools
+    2. AMS remain% delta (fallback) — only for trays not already handled by 3MF
 
     Returns a list of dicts describing what was logged (for WebSocket broadcast).
     """
@@ -98,7 +99,17 @@ async def on_print_complete(
     results = []
     handled_trays: set[tuple[int, int]] = set()
 
-    # --- Path 1: AMS remain% delta (for spools with valid RFID remain data) ---
+    # --- Path 1 (PRIMARY): 3MF per-filament estimates ---
+    if archive_id:
+        print_name = (
+            (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
+        )
+        threemf_results = await _track_from_3mf(
+            printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
+        )
+        results.extend(threemf_results)
+
+    # --- Path 2 (FALLBACK): AMS remain% delta (only for trays not handled by 3MF) ---
     if session and session.tray_remain_start:
         state = printer_manager.get_status(printer_id)
         if state and state.raw_data:
@@ -113,6 +124,9 @@ async def on_print_complete(
                     tray_id = int(tray.get("id", 0))
                     key = (ams_id, tray_id)
 
+                    if key in handled_trays:
+                        continue  # Already tracked via 3MF
+
                     if key not in session.tray_remain_start:
                         continue
 
@@ -174,7 +188,7 @@ async def on_print_complete(
                     )
 
                     logger.info(
-                        "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (%s)",
+                        "[UsageTracker] Spool %d consumed %.1fg (%d%%) on printer %d AMS%d-T%d (AMS fallback, %s)",
                         spool.id,
                         weight_grams,
                         delta_pct,
@@ -184,16 +198,6 @@ async def on_print_complete(
                         status,
                     )
 
-    # --- Path 2: 3MF per-filament estimates (for non-BL spools without remain data) ---
-    if archive_id:
-        print_name = (
-            (session.print_name if session else None) or data.get("subtask_name", "") or data.get("filename", "unknown")
-        )
-        threemf_results = await _track_from_3mf(
-            printer_id, archive_id, status, print_name, handled_trays, printer_manager, db
-        )
-        results.extend(threemf_results)
-
     if results:
         await db.commit()
 
@@ -209,14 +213,20 @@ async def _track_from_3mf(
     printer_manager,
     db: AsyncSession,
 ) -> list[dict]:
-    """Track usage from 3MF per-filament data for non-BL spools.
+    """Track usage from 3MF per-filament slicer data (primary path).
+
+    Uses slicer-estimated filament weight for all spools (BL and non-BL).
+    For partial prints (failed/aborted), tries per-layer gcode data first,
+    then falls back to linear scaling by progress.
 
-    Falls back to slicer-estimated filament weight when AMS remain% is
-    unavailable (non-RFID spools). For partial prints (failed/aborted),
-    scales the estimate by print progress.
+    Slot-to-tray mapping priority:
+    1. Queue item ams_mapping (for queue-initiated prints)
+    2. tray_now from printer state (for single-filament non-queue prints)
+    3. Default mapping: slot_id - 1 = global_tray_id (last resort)
     """
     from backend.app.core.config import settings as app_settings
     from backend.app.models.archive import PrintArchive
+    from backend.app.models.print_queue import PrintQueueItem
     from backend.app.utils.threemf_tools import extract_filament_usage_from_3mf
 
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
@@ -232,6 +242,29 @@ async def _track_from_3mf(
     if not filament_usage:
         return []
 
+    # --- Resolve slot-to-tray mapping ---
+    # 1. Try queue item ams_mapping (queue-initiated prints store the exact mapping)
+    slot_to_tray = None
+    queue_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.archive_id == archive_id)
+        .where(PrintQueueItem.status.in_(["printing", "completed", "failed"]))
+    )
+    queue_item = queue_result.scalar_one_or_none()
+    if queue_item and queue_item.ams_mapping:
+        try:
+            slot_to_tray = json.loads(queue_item.ams_mapping)
+        except (json.JSONDecodeError, TypeError):
+            pass
+
+    # 2. For single-filament non-queue prints, use tray_now from printer state
+    nonzero_slots = [u for u in filament_usage if u.get("used_g", 0) > 0]
+    tray_now_override: int | None = None
+    if not slot_to_tray and len(nonzero_slots) == 1:
+        state = printer_manager.get_status(printer_id)
+        if state and state.tray_now < 255:
+            tray_now_override = state.tray_now
+
     # Scale factor for partial prints (failed/aborted)
     if status == "completed":
         scale = 1.0
@@ -240,6 +273,34 @@ async def _track_from_3mf(
         progress = state.progress if state else 0
         scale = max(0.0, min(progress / 100.0, 1.0))
 
+    # Per-layer gcode accuracy for partial prints
+    layer_grams: dict[int, float] | None = None
+    if status != "completed":
+        state = printer_manager.get_status(printer_id)
+        current_layer = state.layer_num if state else 0
+        if current_layer > 0:
+            try:
+                from backend.app.utils.threemf_tools import (
+                    extract_filament_properties_from_3mf,
+                    extract_layer_filament_usage_from_3mf,
+                    get_cumulative_usage_at_layer,
+                    mm_to_grams,
+                )
+
+                layer_usage = extract_layer_filament_usage_from_3mf(file_path)
+                if layer_usage:
+                    cumulative_mm = get_cumulative_usage_at_layer(layer_usage, current_layer)
+                    filament_props = extract_filament_properties_from_3mf(file_path)
+                    layer_grams = {}
+                    for filament_id, mm_used in cumulative_mm.items():
+                        slot_id = filament_id + 1  # 0-based to 1-based
+                        props = filament_props.get(slot_id, {})
+                        density = props.get("density", 1.24)
+                        diameter = props.get("diameter", 1.75)
+                        layer_grams[slot_id] = mm_to_grams(mm_used, diameter, density)
+            except Exception:
+                pass  # Fall back to linear scaling
+
     results = []
 
     for usage in filament_usage:
@@ -248,8 +309,18 @@ async def _track_from_3mf(
         if used_g <= 0:
             continue
 
-        # Map 3MF slot_id (1-based) to (ams_id, tray_id)
-        global_tray_id = slot_id - 1
+        # Map 3MF slot_id to physical (ams_id, tray_id) using resolved mapping
+        if tray_now_override is not None:
+            # Single-filament non-queue print: use actual tray from printer state
+            global_tray_id = tray_now_override
+        else:
+            # Queue mapping or default: slot_id - 1, overridden by ams_mapping
+            global_tray_id = slot_id - 1
+            if slot_to_tray and slot_id <= len(slot_to_tray):
+                mapped = slot_to_tray[slot_id - 1]
+                if isinstance(mapped, int) and mapped >= 0:
+                    global_tray_id = mapped
+
         if global_tray_id >= 128:
             ams_id = global_tray_id
             tray_id = 0
@@ -259,7 +330,7 @@ async def _track_from_3mf(
 
         key = (ams_id, tray_id)
         if key in handled_trays:
-            continue  # Already tracked via AMS remain% delta
+            continue
 
         # Find spool assignment for this tray
         assign_result = await db.execute(
@@ -279,11 +350,12 @@ async def _track_from_3mf(
         if not spool:
             continue
 
-        # Only use 3MF tracking for non-BL spools (BL spools use AMS remain%)
-        if spool.tag_uid or spool.tray_uuid:
-            continue
+        # Use per-layer grams if available, otherwise linear scale
+        if layer_grams and slot_id in layer_grams:
+            weight_grams = layer_grams[slot_id]
+        else:
+            weight_grams = used_g * scale
 
-        weight_grams = used_g * scale
         if weight_grams <= 0:
             continue
 
@@ -304,6 +376,7 @@ async def _track_from_3mf(
         )
         db.add(history)
 
+        handled_trays.add(key)
         results.append(
             {
                 "spool_id": spool.id,
@@ -314,11 +387,19 @@ async def _track_from_3mf(
             }
         )
 
+        # Determine mapping source for debug logging
+        if tray_now_override is not None:
+            map_src = ", tray_now"
+        elif slot_to_tray:
+            map_src = ", queue_map"
+        else:
+            map_src = ""
         logger.info(
-            "[UsageTracker] Spool %d consumed %.1fg (3MF estimate%s) on printer %d AMS%d-T%d (%s)",
+            "[UsageTracker] Spool %d consumed %.1fg (3MF%s%s) on printer %d AMS%d-T%d (%s)",
             spool.id,
             weight_grams,
-            f" scaled to {scale:.0%}" if scale < 1 else "",
+            " per-layer" if (layer_grams and slot_id in layer_grams) else (f" scaled {scale:.0%}" if scale < 1 else ""),
+            map_src,
             printer_id,
             ams_id,
             tray_id,

+ 63 - 0
backend/app/utils/printer_models.py

@@ -48,6 +48,69 @@ PRINTER_MODEL_ID_MAP = {
 }
 
 
+# Rod/rail type classification for maintenance tasks.
+# Carbon rods: X1, P1, P2S series (CoreXY with carbon fiber rods)
+# Linear rails: A1, H2 series (linear rail motion system)
+# Values must be uppercase with spaces stripped for normalized comparison.
+CARBON_ROD_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "X1",
+        "X1C",
+        "X1E",
+        "P1P",
+        "P1S",
+        "P2S",
+        # Internal codes
+        "C11",  # X1C
+        "C12",  # X1
+        "C13",  # X1E
+        "N7",  # P2S
+    ]
+)
+
+LINEAR_RAIL_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "A1",
+        "A1MINI",
+        "H2D",
+        "H2DPRO",
+        "H2C",
+        "H2S",
+        # Internal codes
+        "N1",  # A1
+        "N2S",  # A1 Mini
+        "A04",  # A1 Mini (alternate)
+        "A11",  # A1
+        "A12",  # A1 Mini
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro (alternate)
+        "O1C",  # H2C
+        "O1S",  # H2S
+    ]
+)
+
+
+def get_rod_type(model: str | None) -> str | None:
+    """Return the rod/rail type for a printer model.
+
+    Returns:
+        "carbon" for X1/P1/P2S series (carbon fiber rods),
+        "linear_rail" for A1/H2 series (linear rails),
+        None for unknown models.
+    """
+    if not model:
+        return None
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    if normalized in CARBON_ROD_MODELS:
+        return "carbon"
+    if normalized in LINEAR_RAIL_MODELS:
+        return "linear_rail"
+    return None
+
+
 def normalize_printer_model_id(model_id: str | None) -> str | None:
     """Convert printer_model_id (internal code) to normalized short name.
 

+ 56 - 0
backend/app/utils/threemf_tools.py

@@ -264,6 +264,62 @@ def extract_filament_properties_from_3mf(file_path: Path) -> dict[int, dict]:
     return properties
 
 
+def extract_nozzle_mapping_from_3mf(zf: zipfile.ZipFile) -> dict[int, int] | None:
+    """Extract per-slot nozzle/extruder mapping from a 3MF file's project settings.
+
+    On dual-nozzle printers (H2D, H2D Pro), each filament slot is assigned to a
+    specific nozzle. This reads the slicer's nozzle assignment from
+    Metadata/project_settings.config.
+
+    Translation chain:
+        filament_nozzle_map[slot_id - 1] -> slicer extruder index
+        physical_extruder_map[slicer_ext] -> MQTT extruder ID (0=right, 1=left)
+
+    Args:
+        zf: An open ZipFile of the 3MF archive
+
+    Returns:
+        Dictionary mapping {slot_id: extruder_id} for dual-nozzle files,
+        or None if single-nozzle, missing data, or parse error.
+    """
+    try:
+        if "Metadata/project_settings.config" not in zf.namelist():
+            return None
+
+        content = zf.read("Metadata/project_settings.config").decode()
+        data = json.loads(content)
+
+        filament_nozzle_map = data.get("filament_nozzle_map")
+        physical_extruder_map = data.get("physical_extruder_map")
+
+        if not filament_nozzle_map or not physical_extruder_map:
+            return None
+
+        # Build slot_id (1-based) -> extruder_id mapping
+        nozzle_mapping: dict[int, int] = {}
+        for i, slicer_ext_str in enumerate(filament_nozzle_map):
+            slot_id = i + 1
+            try:
+                slicer_ext = int(slicer_ext_str)
+                if slicer_ext < len(physical_extruder_map):
+                    extruder_id = int(physical_extruder_map[slicer_ext])
+                    nozzle_mapping[slot_id] = extruder_id
+            except (ValueError, TypeError, IndexError):
+                pass  # Skip slots with unparseable nozzle mapping
+
+        if not nozzle_mapping:
+            return None
+
+        # If all slots map to the same extruder, this is a single-nozzle printer
+        unique_extruders = set(nozzle_mapping.values())
+        if len(unique_extruders) <= 1:
+            return None
+
+        return nozzle_mapping
+    except Exception:
+        return None
+
+
 def extract_filament_usage_from_3mf(file_path: Path) -> list[dict]:
     """Extract per-filament total usage from 3MF slice_info.config.
 

+ 6 - 6
backend/tests/unit/services/test_bambu_ftp.py

@@ -123,11 +123,10 @@ class TestDisconnectServerGone:
     """Test disconnect behavior when the server has stopped."""
 
     def test_disconnect_after_server_gone(self, ftp_certs, tmp_path):
-        """Disconnect after server has stopped raises EOFError.
+        """Disconnect after server has stopped does not raise.
 
-        Note: The current disconnect() catches (OSError, ftplib.Error) but
-        EOFError is neither. This documents actual behavior — a future fix
-        could add EOFError to the except clause.
+        disconnect() catches OSError, ftplib.Error, and EOFError so that
+        best-effort cleanup never propagates exceptions to the caller.
         """
         from backend.tests.unit.services.mock_ftp_server import (
             MockBambuFTPServer,
@@ -145,8 +144,9 @@ class TestDisconnectServerGone:
         client.connect()
 
         server.stop()
-        with pytest.raises(EOFError):
-            client.disconnect()
+        # Should not raise — disconnect() catches all connection errors
+        client.disconnect()
+        assert client._ftp is None
 
 
 # ---------------------------------------------------------------------------

+ 66 - 0
backend/tests/unit/services/test_spoolman_service.py

@@ -2,6 +2,7 @@
 
 These tests specifically target the sync_ams_tray method's disable_weight_sync
 functionality that controls whether remaining_weight is updated.
+Also includes tests for is_bambu_lab_spool RFID detection.
 """
 
 from unittest.mock import AsyncMock, Mock, patch
@@ -11,6 +12,71 @@ import pytest
 from backend.app.services.spoolman import AMSTray, SpoolmanClient
 
 
+class TestIsBambuLabSpool:
+    """Tests for is_bambu_lab_spool — detects BL spools via RFID hardware identifiers only."""
+
+    @pytest.fixture
+    def client(self):
+        return SpoolmanClient("http://localhost:7912")
+
+    def test_valid_tray_uuid_returns_true(self, client):
+        """A non-zero 32-char hex tray_uuid identifies a BL spool."""
+        assert client.is_bambu_lab_spool("A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4") is True
+
+    def test_valid_tag_uid_returns_true(self, client):
+        """A non-zero 16-char hex tag_uid identifies a BL spool (fallback)."""
+        assert client.is_bambu_lab_spool("", tag_uid="A1B2C3D4E5F6A1B2") is True
+
+    def test_zero_tray_uuid_returns_false(self, client):
+        """All-zero tray_uuid means no RFID tag read."""
+        assert client.is_bambu_lab_spool("00000000000000000000000000000000") is False
+
+    def test_zero_tag_uid_returns_false(self, client):
+        """All-zero tag_uid means no RFID tag read."""
+        assert client.is_bambu_lab_spool("", tag_uid="0000000000000000") is False
+
+    def test_empty_identifiers_returns_false(self, client):
+        """No identifiers means no BL spool."""
+        assert client.is_bambu_lab_spool("") is False
+        assert client.is_bambu_lab_spool("", tag_uid="") is False
+
+    def test_tray_info_idx_ignored(self, client):
+        """tray_info_idx is NOT a reliable BL indicator — third-party spools
+        using Bambu generic presets also have GF-prefixed tray_info_idx values."""
+        # Third-party spool with Bambu preset but no RFID identifiers
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFA00") is False
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFB00") is False
+        assert client.is_bambu_lab_spool("", tray_info_idx="GFSA02_04") is False
+
+    def test_tray_info_idx_with_valid_uuid_returns_true(self, client):
+        """BL spool with both RFID UUID and preset ID — detected by UUID."""
+        assert (
+            client.is_bambu_lab_spool(
+                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                tray_info_idx="GFA00",
+            )
+            is True
+        )
+
+    def test_tray_uuid_preferred_over_tag_uid(self, client):
+        """tray_uuid is checked before tag_uid (both valid)."""
+        assert (
+            client.is_bambu_lab_spool(
+                "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4",
+                tag_uid="A1B2C3D4E5F6A1B2",
+            )
+            is True
+        )
+
+    def test_short_tray_uuid_returns_false(self, client):
+        """UUID must be exactly 32 hex chars."""
+        assert client.is_bambu_lab_spool("A1B2C3D4") is False
+
+    def test_non_hex_tray_uuid_returns_false(self, client):
+        """UUID must be valid hex."""
+        assert client.is_bambu_lab_spool("ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ") is False
+
+
 class TestSpoolmanClient:
     """Tests for SpoolmanClient class."""
 

+ 27 - 14
backend/tests/unit/services/test_usage_tracker.py

@@ -1,7 +1,7 @@
 """Unit tests for the filament usage tracker.
 
-Tests both AMS remain% delta tracking (Path 1) and 3MF per-filament
-fallback tracking (Path 2) for non-BL spools.
+Tests 3MF-primary tracking (Path 1) and AMS remain% delta fallback
+(Path 2) for spools not covered by 3MF data.
 """
 
 from datetime import datetime, timezone
@@ -40,11 +40,13 @@ def _make_assignment(*, spool_id=1, printer_id=1, ams_id=0, tray_id=0):
     return assignment
 
 
-def _make_printer_state(ams_data, progress=0):
+def _make_printer_state(ams_data, progress=0, layer_num=0, tray_now=255):
     """Create a mock printer state with AMS data."""
     state = MagicMock()
     state.raw_data = {"ams": ams_data}
     state.progress = progress
+    state.layer_num = layer_num
+    state.tray_now = tray_now
     return state
 
 
@@ -189,16 +191,17 @@ class TestTrackFrom3MF:
         archive.file_path = "archives/test.3mf"
 
         db = AsyncMock()
-        # First execute → archive, second → assignment, third → spool
+        # archive, queue_item(None), assignment, spool
         db.execute = AsyncMock(
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
             ]
         )
 
-        pm = _make_printer_manager()
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
         filament_usage = [{"slot_id": 1, "used_g": 25.5, "type": "PLA", "color": "#FF0000"}]
 
         with (
@@ -234,16 +237,18 @@ class TestTrackFrom3MF:
         archive.file_path = "archives/test.3mf"
 
         db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
         db.execute = AsyncMock(
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
             ]
         )
 
         # Print failed at 50% progress → 50g consumed from 100g estimate
-        pm = _make_printer_manager(_make_printer_state([], progress=50))
+        pm = _make_printer_manager(_make_printer_state([], progress=50, tray_now=0))
         filament_usage = [{"slot_id": 1, "used_g": 100.0, "type": "PLA", "color": ""}]
 
         with (
@@ -269,23 +274,25 @@ class TestTrackFrom3MF:
         assert spool.weight_used == 50.0
 
     @pytest.mark.asyncio
-    async def test_skips_bl_spools(self):
-        """BL spools (with tag_uid) are NOT tracked via 3MF — they use AMS remain%."""
+    async def test_tracks_bl_spools_via_3mf(self):
+        """BL spools (with tag_uid) ARE now tracked via 3MF (unified tracking)."""
         spool = _make_spool(tag_uid="ABCD1234", tray_uuid="A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4")
         assignment = _make_assignment()
         archive = MagicMock()
         archive.file_path = "archives/test.3mf"
 
         db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
         db.execute = AsyncMock(
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
             ]
         )
 
-        pm = _make_printer_manager()
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
         filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
 
         with (
@@ -306,7 +313,9 @@ class TestTrackFrom3MF:
                 db=db,
             )
 
-        assert results == []
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 50.0
 
     @pytest.mark.asyncio
     async def test_skips_already_handled_trays(self):
@@ -315,13 +324,15 @@ class TestTrackFrom3MF:
         archive.file_path = "archives/test.3mf"
 
         db = AsyncMock()
+        # archive, queue_item(None)
         db.execute = AsyncMock(
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
             ]
         )
 
-        pm = _make_printer_manager()
+        pm = _make_printer_manager(_make_printer_state([], tray_now=0))
         filament_usage = [{"slot_id": 1, "used_g": 50.0, "type": "PLA", "color": ""}]
 
         with (
@@ -346,23 +357,25 @@ class TestTrackFrom3MF:
 
     @pytest.mark.asyncio
     async def test_slot_to_tray_mapping(self):
-        """3MF slot_id maps correctly to (ams_id, tray_id)."""
-        # slot 5 → global_tray_id 4 → ams_id=1, tray_id=0
+        """3MF slot_id maps correctly to (ams_id, tray_id) via tray_now."""
+        # tray_now=4 → ams_id=1, tray_id=0 (single filament uses tray_now)
         spool = _make_spool(id=9)
         assignment = _make_assignment(spool_id=9, ams_id=1, tray_id=0)
         archive = MagicMock()
         archive.file_path = "archives/test.3mf"
 
         db = AsyncMock()
+        # archive, queue_item(None), assignment, spool
         db.execute = AsyncMock(
             side_effect=[
                 MagicMock(scalar_one_or_none=MagicMock(return_value=archive)),
+                MagicMock(scalar_one_or_none=MagicMock(return_value=None)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=assignment)),
                 MagicMock(scalar_one_or_none=MagicMock(return_value=spool)),
             ]
         )
 
-        pm = _make_printer_manager()
+        pm = _make_printer_manager(_make_printer_state([], tray_now=4))
         filament_usage = [{"slot_id": 5, "used_g": 30.0, "type": "PETG", "color": ""}]
 
         with (

+ 188 - 0
backend/tests/unit/test_scheduler_ams_mapping.py

@@ -1,8 +1,13 @@
 """Tests for the AMS mapping computation in the print scheduler."""
 
+import io
+import json
+import zipfile
+
 import pytest
 
 from backend.app.services.print_scheduler import PrintScheduler
+from backend.app.utils.threemf_tools import extract_nozzle_mapping_from_3mf
 
 
 class TestSchedulerAmsMappingHelpers:
@@ -467,3 +472,186 @@ class TestBuildLoadedFilamentsTrayInfoIdx:
         assert len(result) == 1
         assert result[0]["tray_info_idx"] == "P4d64437"
         assert result[0]["is_external"] is True
+
+
+def _make_3mf_zip(project_settings: dict | None = None) -> zipfile.ZipFile:
+    """Create an in-memory ZipFile mimicking a 3MF with project_settings.config."""
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        if project_settings is not None:
+            zf.writestr("Metadata/project_settings.config", json.dumps(project_settings))
+    buf.seek(0)
+    return zipfile.ZipFile(buf, "r")
+
+
+class TestExtractNozzleMappingFrom3mf:
+    """Test the extract_nozzle_mapping_from_3mf utility."""
+
+    def test_dual_nozzle_mapping(self):
+        """Should return slot->extruder mapping for dual-nozzle files."""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1", "0"],
+                "physical_extruder_map": ["0", "1"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 0, 2: 1, 3: 0}
+        zf.close()
+
+    def test_single_nozzle_returns_none(self):
+        """All slots on same extruder should return None (single-nozzle)."""
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "0", "0"],
+                "physical_extruder_map": ["0"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_missing_project_settings_returns_none(self):
+        """Missing project_settings.config should return None."""
+        zf = _make_3mf_zip(None)
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_missing_fields_returns_none(self):
+        """Missing filament_nozzle_map or physical_extruder_map should return None."""
+        zf = _make_3mf_zip({"some_other_key": "value"})
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result is None
+        zf.close()
+
+    def test_physical_extruder_map_remapping(self):
+        """Should apply physical_extruder_map to remap slicer extruder to MQTT extruder."""
+        # Slicer ext 0 -> MQTT ext 1, slicer ext 1 -> MQTT ext 0
+        zf = _make_3mf_zip(
+            {
+                "filament_nozzle_map": ["0", "1"],
+                "physical_extruder_map": ["1", "0"],
+            }
+        )
+        result = extract_nozzle_mapping_from_3mf(zf)
+        assert result == {1: 1, 2: 0}
+        zf.close()
+
+
+class TestNozzleAwareMapping:
+    """Test nozzle-aware filament matching in the print scheduler."""
+
+    @pytest.fixture
+    def scheduler(self):
+        return PrintScheduler()
+
+    def test_dual_nozzle_matching(self, scheduler):
+        """Filaments assigned to different nozzles should match to correct AMS units."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
+            {"slot_id": 2, "type": "PLA", "color": "#00FF00", "nozzle_id": 1},  # Left nozzle
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#00FF00", "global_tray_id": 0, "extruder_id": 0},  # AMS0 on right
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},  # AMS1 on left
+        ]
+        # Without nozzle filtering, slot 1 (red, right) would match tray 4 (red, left) by color.
+        # With nozzle filtering, slot 1 (right nozzle) can only use tray 0 (right extruder),
+        # and slot 2 (left nozzle) can only use tray 4 (left extruder).
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 4]
+
+    def test_nozzle_fallback_when_no_match(self, scheduler):
+        """Should fall back to unfiltered list when nozzle-filtered list is empty."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000", "nozzle_id": 0},  # Right nozzle
+        ]
+        loaded = [
+            # Only a tray on the left nozzle, none on right
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
+        ]
+        # No trays on extruder 0, so fallback to unfiltered -> should still match
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [4]
+
+    def test_no_nozzle_id_skips_filtering(self, scheduler):
+        """When nozzle_id is None, no nozzle filtering should be applied."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#FF0000"},  # No nozzle_id
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 0, "extruder_id": 0},
+            {"type": "PLA", "color": "#FF0000", "global_tray_id": 4, "extruder_id": 1},
+        ]
+        # Should match first available (tray 0) regardless of extruder
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0]
+
+    def test_extruder_id_in_loaded_filaments(self, scheduler):
+        """_build_loaded_filaments should include extruder_id from ams_extruder_map."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]},
+                    {"id": 1, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "00FF00"}]},
+                ],
+                "ams_extruder_map": {"0": 0, "1": 1},
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 2
+        assert result[0]["extruder_id"] == 0
+        assert result[1]["extruder_id"] == 1
+
+    def test_extruder_id_none_without_map(self, scheduler):
+        """extruder_id should be None when ams_extruder_map is absent."""
+
+        class MockStatus:
+            raw_data = {
+                "ams": [
+                    {"id": 0, "tray": [{"id": 0, "tray_type": "PLA", "tray_color": "FF0000"}]},
+                ]
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] is None
+
+    def test_external_spool_extruder_id(self, scheduler):
+        """External spool should have extruder_id=0 when ams_extruder_map exists."""
+
+        class MockStatus:
+            raw_data = {
+                "vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"},
+                "ams_extruder_map": {"0": 0},
+            }
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] == 0
+        assert result[0]["is_external"] is True
+
+    def test_external_spool_no_extruder_map(self, scheduler):
+        """External spool extruder_id should be None without ams_extruder_map."""
+
+        class MockStatus:
+            raw_data = {"vt_tray": {"tray_type": "TPU", "tray_color": "0000FF"}}
+
+        result = scheduler._build_loaded_filaments(MockStatus())
+        assert len(result) == 1
+        assert result[0]["extruder_id"] is None
+
+    def test_dual_nozzle_with_tray_info_idx(self, scheduler):
+        """Nozzle filtering should work together with tray_info_idx matching."""
+        required = [
+            {"slot_id": 1, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA00", "nozzle_id": 0},
+            {"slot_id": 2, "type": "PLA", "color": "#000000", "tray_info_idx": "GFA01", "nozzle_id": 1},
+        ]
+        loaded = [
+            {"type": "PLA", "color": "#000000", "global_tray_id": 0, "tray_info_idx": "GFA00", "extruder_id": 0},
+            {"type": "PLA", "color": "#000000", "global_tray_id": 4, "tray_info_idx": "GFA01", "extruder_id": 1},
+        ]
+        result = scheduler._match_filaments_to_slots(required, loaded)
+        assert result == [0, 4]

+ 726 - 0
backend/tests/unit/test_usage_tracker.py

@@ -0,0 +1,726 @@
+"""Unit tests for usage_tracker.py — 3MF-primary filament tracking.
+
+Tests the unified tracking logic: 3MF slicer estimates as primary path,
+AMS remain% delta as fallback, per-layer gcode for partial prints,
+slot-to-tray mapping resolution, and notification variable formatting.
+"""
+
+from datetime import datetime, timezone
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.usage_tracker import (
+    PrintSession,
+    _active_sessions,
+    _track_from_3mf,
+    on_print_complete,
+    on_print_start,
+)
+
+
+def _make_spool(spool_id=1, label_weight=1000, weight_used=0, tag_uid=None, tray_uuid=None):
+    """Create a mock Spool object."""
+    spool = MagicMock()
+    spool.id = spool_id
+    spool.label_weight = label_weight
+    spool.weight_used = weight_used
+    spool.tag_uid = tag_uid
+    spool.tray_uuid = tray_uuid
+    spool.last_used = None
+    return spool
+
+
+def _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0):
+    """Create a mock SpoolAssignment object."""
+    assignment = MagicMock()
+    assignment.spool_id = spool_id
+    assignment.printer_id = printer_id
+    assignment.ams_id = ams_id
+    assignment.tray_id = tray_id
+    return assignment
+
+
+def _make_archive(archive_id=1, file_path="archives/1/test.3mf", extra_data=None):
+    """Create a mock PrintArchive object."""
+    archive = MagicMock()
+    archive.id = archive_id
+    archive.file_path = file_path
+    archive.extra_data = extra_data
+    return archive
+
+
+def _make_queue_item(ams_mapping=None, status="printing"):
+    """Create a mock PrintQueueItem object."""
+    item = MagicMock()
+    item.ams_mapping = ams_mapping
+    item.status = status
+    return item
+
+
+def _mock_db_execute(*return_values):
+    """Create a mock db with execute() that returns values in sequence."""
+    db = AsyncMock()
+    results = []
+    for val in return_values:
+        result = MagicMock()
+        result.scalar_one_or_none.return_value = val
+        results.append(result)
+    db.execute = AsyncMock(side_effect=results)
+    return db
+
+
+def _mock_db_sequential(responses):
+    """Create mock db that returns responses in order."""
+    db = AsyncMock()
+    call_count = [0]
+
+    async def mock_execute(*args, **kwargs):
+        idx = call_count[0]
+        call_count[0] += 1
+        result = MagicMock()
+        if idx < len(responses):
+            result.scalar_one_or_none.return_value = responses[idx]
+        else:
+            result.scalar_one_or_none.return_value = None
+        return result
+
+    db.execute = mock_execute
+    return db
+
+
+class TestOnPrintStart:
+    """Tests for on_print_start()."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_captures_remain_data(self):
+        """Captures AMS remain% at print start."""
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 80}, {"id": 1, "remain": 50}]}]}
+        )
+
+        await on_print_start(1, {"subtask_name": "Benchy"}, printer_manager)
+
+        assert 1 in _active_sessions
+        session = _active_sessions[1]
+        assert session.print_name == "Benchy"
+        assert session.tray_remain_start == {(0, 0): 80, (0, 1): 50}
+
+    @pytest.mark.asyncio
+    async def test_creates_session_without_remain(self):
+        """Creates session even without valid remain data (for 3MF tracking)."""
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": -1}]}]}
+        )
+
+        await on_print_start(1, {"subtask_name": "Test"}, printer_manager)
+
+        assert 1 in _active_sessions
+        assert _active_sessions[1].tray_remain_start == {}
+
+
+class TestOnPrintComplete:
+    """Tests for on_print_complete() — path ordering and interaction."""
+
+    @pytest.fixture(autouse=True)
+    def _clear_sessions(self):
+        _active_sessions.clear()
+        yield
+        _active_sessions.clear()
+
+    @pytest.mark.asyncio
+    async def test_bl_spool_uses_3mf(self):
+        """BL spool (with tag_uid) is tracked via 3MF, not just AMS delta."""
+        spool = _make_spool(spool_id=1, tag_uid="AABB1122", label_weight=1000)
+        assignment = _make_assignment(spool_id=1, printer_id=1, ams_id=0, tray_id=0)
+        archive = _make_archive(archive_id=10)
+
+        # Setup: session with AMS remain data
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        # Mock printer state: tray_now=0 (AMS0-T0), single filament
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        # 3MF path should handle it (BL guard removed)
+        assert len(results) >= 1
+        assert results[0]["spool_id"] == 1
+        assert results[0]["weight_used"] == 15.0
+
+    @pytest.mark.asyncio
+    async def test_ams_delta_fallback_no_archive(self):
+        """AMS delta tracks consumption when archive_id is None."""
+        spool = _make_spool(spool_id=2, label_weight=1000)
+        assignment = _make_assignment(spool_id=2)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Test",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+        )
+
+        # db returns assignment then spool
+        db = _mock_db_sequential([assignment, spool])
+
+        results = await on_print_complete(
+            printer_id=1,
+            data={"status": "completed"},
+            printer_manager=printer_manager,
+            db=db,
+            archive_id=None,
+        )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 2
+        # 10% of 1000g = 100g
+        assert results[0]["weight_used"] == 100.0
+        assert results[0]["percent_used"] == 10
+
+    @pytest.mark.asyncio
+    async def test_no_double_tracking(self):
+        """When 3MF handles a tray, AMS delta skips it."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        _active_sessions[1] = PrintSession(
+            printer_id=1,
+            print_name="Benchy",
+            started_at=datetime.now(timezone.utc),
+            tray_remain_start={(0, 0): 80},
+        )
+
+        # tray_now=0 matches the single filament slot
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            raw_data={"ams": [{"id": 0, "tray": [{"id": 0, "remain": 70}]}]},
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        # db returns: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        filament_usage = [{"slot_id": 1, "used_g": 15.0, "type": "PLA", "color": "#FF0000"}]
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await on_print_complete(
+                printer_id=1,
+                data={"status": "completed"},
+                printer_manager=printer_manager,
+                db=db,
+                archive_id=10,
+            )
+
+        # Only 1 result (3MF), NOT 2 (3MF + AMS delta)
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 15.0
+
+
+class TestTrackFrom3mf:
+    """Tests for _track_from_3mf() — per-layer, linear scaling, and slot mapping."""
+
+    @pytest.mark.asyncio
+    async def test_linear_fallback_for_partial_print(self):
+        """Falls back to linear scaling when gcode layer data unavailable."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=50,
+            layer_num=25,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value=None,  # No layer data available
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="failed",
+                print_name="Benchy",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        # 50% of 20g = 10g
+        assert results[0]["weight_used"] == 10.0
+        # Tray should be marked as handled
+        assert (0, 0) in handled_trays
+
+    @pytest.mark.asyncio
+    async def test_per_layer_partial_print(self):
+        """Failed print at layer N uses gcode cumulative data."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=50,
+            layer_num=25,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
+        # Per-layer data: at layer 25, filament 0 used 5000mm
+        layer_data = {10: {0: 2000.0}, 25: {0: 5000.0}, 50: {0: 10000.0}}
+        filament_props = {1: {"density": 1.24, "diameter": 1.75}}
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_layer_filament_usage_from_3mf",
+                return_value=layer_data,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.get_cumulative_usage_at_layer",
+                return_value={0: 5000.0},
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_properties_from_3mf",
+                return_value=filament_props,
+            ),
+            patch(
+                "backend.app.utils.threemf_tools.mm_to_grams",
+                return_value=12.0,  # 5000mm at 1.75mm/1.24g/cm3
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="failed",
+                print_name="Benchy",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        # Should use per-layer grams (12.0g), not linear scale (10.0g)
+        assert results[0]["weight_used"] == 12.0
+
+    @pytest.mark.asyncio
+    async def test_completed_print_uses_full_weight(self):
+        """Completed print uses full 3MF weight (scale=1.0)."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=0,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 20.0, "type": "PLA", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Benchy",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["weight_used"] == 20.0
+
+    @pytest.mark.asyncio
+    async def test_tray_now_override_for_single_filament(self):
+        """Single-filament non-queue print uses tray_now instead of slot_id mapping."""
+        # Spool 2 is at AMS1-T3 (global_tray_id=7)
+        spool = _make_spool(spool_id=2, label_weight=1000)
+        assignment = _make_assignment(spool_id=2, ams_id=1, tray_id=3)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool
+        db = _mock_db_sequential([archive, None, assignment, spool])
+
+        # tray_now=7 = (ams_id=1, tray_id=3), the ACTUAL tray used
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=7,
+        )
+
+        # 3MF has slot_id=12 (would default-map to ams_id=2, tray_id=3 — WRONG)
+        filament_usage = [{"slot_id": 12, "used_g": 10.6, "type": "PLA", "color": "#FF0000"}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 2
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 3
+        assert results[0]["weight_used"] == 10.6
+        assert (1, 3) in handled_trays
+
+    @pytest.mark.asyncio
+    async def test_queue_ams_mapping_overrides_default(self):
+        """Queue item ams_mapping overrides default slot_id mapping."""
+        # Spool at AMS1-T3 (global_tray_id=7)
+        spool = _make_spool(spool_id=5, label_weight=1000)
+        assignment = _make_assignment(spool_id=5, ams_id=1, tray_id=3)
+        archive = _make_archive(archive_id=20)
+        # Queue item maps slot 1 → global tray 7 (ams_id=1, tray_id=3)
+        queue_item = _make_queue_item(ams_mapping="[7, -1, -1, -1]")
+
+        # db: archive, queue_item, assignment, spool
+        db = _mock_db_sequential([archive, queue_item, assignment, spool])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=7,
+        )
+
+        filament_usage = [{"slot_id": 1, "used_g": 25.0, "type": "PETG", "color": ""}]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=20,
+                status="completed",
+                print_name="Queue Print",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 1
+        assert results[0]["spool_id"] == 5
+        assert results[0]["ams_id"] == 1
+        assert results[0]["tray_id"] == 3
+        assert results[0]["weight_used"] == 25.0
+
+    @pytest.mark.asyncio
+    async def test_multi_filament_uses_queue_mapping(self):
+        """Multi-filament queue prints use ams_mapping for each slot."""
+        spool_a = _make_spool(spool_id=1, label_weight=1000)
+        spool_b = _make_spool(spool_id=2, label_weight=1000)
+        assign_a = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
+        assign_b = _make_assignment(spool_id=2, ams_id=1, tray_id=2)
+        archive = _make_archive(archive_id=30)
+        # slot 1 → tray 0 (AMS0-T0), slot 2 → tray 6 (AMS1-T2)
+        queue_item = _make_queue_item(ams_mapping="[0, 6]")
+
+        # db: archive, queue_item, assign_a, spool_a, assign_b, spool_b
+        db = _mock_db_sequential([archive, queue_item, assign_a, spool_a, assign_b, spool_b])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=6,
+        )
+
+        filament_usage = [
+            {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
+            {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
+        ]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=30,
+                status="completed",
+                print_name="Multi",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        assert len(results) == 2
+        assert results[0]["spool_id"] == 1
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 0
+        assert results[0]["weight_used"] == 10.0
+        assert results[1]["spool_id"] == 2
+        assert results[1]["ams_id"] == 1
+        assert results[1]["tray_id"] == 2
+        assert results[1]["weight_used"] == 5.0
+
+    @pytest.mark.asyncio
+    async def test_no_tray_now_override_for_multi_filament(self):
+        """Multi-filament non-queue prints fall back to default mapping, not tray_now."""
+        spool = _make_spool(spool_id=1, label_weight=1000)
+        assignment = _make_assignment(spool_id=1, ams_id=0, tray_id=0)
+        archive = _make_archive(archive_id=10)
+
+        # db: archive, queue_item(None), assignment, spool (2nd slot has no assignment)
+        db = _mock_db_sequential([archive, None, assignment, spool, None])
+
+        printer_manager = MagicMock()
+        printer_manager.get_status.return_value = SimpleNamespace(
+            progress=100,
+            layer_num=50,
+            tray_now=4,  # tray_now won't be used
+        )
+
+        # Two filament slots with usage
+        filament_usage = [
+            {"slot_id": 1, "used_g": 10.0, "type": "PLA", "color": ""},
+            {"slot_id": 2, "used_g": 5.0, "type": "PETG", "color": ""},
+        ]
+        handled_trays: set[tuple[int, int]] = set()
+
+        with (
+            patch("backend.app.core.config.settings") as mock_settings,
+            patch(
+                "backend.app.utils.threemf_tools.extract_filament_usage_from_3mf",
+                return_value=filament_usage,
+            ),
+        ):
+            mock_settings.base_dir = MagicMock()
+            mock_path = MagicMock()
+            mock_path.exists.return_value = True
+            mock_settings.base_dir.__truediv__ = MagicMock(return_value=mock_path)
+
+            results = await _track_from_3mf(
+                printer_id=1,
+                archive_id=10,
+                status="completed",
+                print_name="Test",
+                handled_trays=handled_trays,
+                printer_manager=printer_manager,
+                db=db,
+            )
+
+        # Should use default mapping (slot 1 → tray 0, slot 2 → tray 1)
+        assert len(results) == 1  # Only slot 1 has assignment
+        assert results[0]["ams_id"] == 0
+        assert results[0]["tray_id"] == 0
+
+
+class TestNotificationVariables:
+    """Tests for filament_details formatting in notifications."""
+
+    def test_filament_details_single_slot(self):
+        """Single slot produces 'PLA: 15.2g' format."""
+        slots = [{"type": "PLA", "used_g": 15.2, "slot_id": 1, "color": "#FF0000"}]
+        parts = []
+        for slot in slots:
+            ftype = slot.get("type", "Unknown") or "Unknown"
+            used = slot.get("used_g", 0)
+            parts.append(f"{ftype}: {used:.1f}g")
+        result = " | ".join(parts)
+        assert result == "PLA: 15.2g"
+
+    def test_filament_details_multi_slot(self):
+        """Multiple slots produce 'PLA: 10.0g | PETG: 5.0g' format."""
+        slots = [
+            {"type": "PLA", "used_g": 10.0, "slot_id": 1, "color": ""},
+            {"type": "PETG", "used_g": 5.0, "slot_id": 2, "color": ""},
+        ]
+        parts = []
+        for slot in slots:
+            ftype = slot.get("type", "Unknown") or "Unknown"
+            used = slot.get("used_g", 0)
+            parts.append(f"{ftype}: {used:.1f}g")
+        result = " | ".join(parts)
+        assert result == "PLA: 10.0g | PETG: 5.0g"
+
+    def test_filament_details_empty_type(self):
+        """Empty type defaults to 'Unknown'."""
+        slots = [{"type": "", "used_g": 5.0, "slot_id": 1, "color": ""}]
+        parts = []
+        for slot in slots:
+            ftype = slot.get("type", "Unknown") or "Unknown"
+            used = slot.get("used_g", 0)
+            parts.append(f"{ftype}: {used:.1f}g")
+        result = " | ".join(parts)
+        assert result == "Unknown: 5.0g"
+
+    def test_filament_grams_scaled_for_partial(self):
+        """filament_grams is scaled by progress for partial prints."""
+        filament_used_grams = 20.0
+        progress = 50
+        scale = max(0.0, min(progress / 100.0, 1.0))
+        scaled = round(filament_used_grams * scale, 1)
+        assert scaled == 10.0
+
+    def test_filament_grams_zero_progress(self):
+        """Progress=0 at cancellation gives 0.0g."""
+        filament_used_grams = 20.0
+        progress = 0
+        scale = max(0.0, min(progress / 100.0, 1.0))
+        scaled = round(filament_used_grams * scale, 1)
+        assert scaled == 0.0
+
+    def test_slot_scaling_for_partial(self):
+        """Per-slot usage is scaled linearly for partial prints."""
+        slots = [
+            {"type": "PLA", "used_g": 20.0, "slot_id": 1, "color": ""},
+            {"type": "PETG", "used_g": 10.0, "slot_id": 2, "color": ""},
+        ]
+        progress = 30
+        scale = max(0.0, min(progress / 100.0, 1.0))
+        scaled_slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
+        assert scaled_slots[0]["used_g"] == 6.0
+        assert scaled_slots[1]["used_g"] == 3.0

+ 179 - 0
frontend/src/__tests__/hooks/useFilamentMapping.test.ts

@@ -347,3 +347,182 @@ describe('computeAmsMapping', () => {
     expect(result).toEqual([254]);  // External spool global ID
   });
 });
+
+describe('buildLoadedFilaments - nozzle awareness', () => {
+  it('sets extruderId from ams_extruder_map', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].extruderId).toBe(1);  // AMS 0 → left nozzle
+    expect(result[1].extruderId).toBe(0);  // AMS 1 → right nozzle
+  });
+
+  it('leaves extruderId undefined when no ams_extruder_map', () => {
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+
+    const result = buildLoadedFilaments(status);
+
+    expect(result[0].extruderId).toBeUndefined();
+  });
+});
+
+describe('computeAmsMapping - nozzle filtering', () => {
+  it('filters candidates by nozzle_id when set', () => {
+    // Filament requires left nozzle (extruder 1), only AMS 0 is on left
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // AMS 0, tray 0 (on left nozzle)
+  });
+
+  it('filters to right nozzle when nozzle_id=0', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 0 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([4]);  // AMS 1, tray 0 (global ID = 1*4+0 = 4, on right nozzle)
+  });
+
+  it('falls back to all trays when target nozzle has no trays at all', () => {
+    // Requires nozzle_id=1 (left), but no AMS units are on left nozzle
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Right nozzle only
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 0 };  // AMS 0 → right nozzle, none on left
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0]);  // Falls back to unfiltered (right nozzle PLA)
+  });
+
+  it('stays restricted when target nozzle has trays but wrong type', () => {
+    // Left nozzle has PETG, right has PLA — but requires PLA on left
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle - only PETG
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+      {
+        id: 1,  // Right nozzle - has PLA
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([-1]);  // No PLA on left nozzle, stays restricted
+  });
+
+  it('skips nozzle filtering when nozzle_id is undefined', () => {
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10 },  // No nozzle_id
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,
+        tray: [{ id: 0, tray_type: 'PETG', tray_color: '00FF00' }],
+      },
+      {
+        id: 1,
+        tray: [{ id: 0, tray_type: 'PLA', tray_color: 'FF0000' }],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([4]);  // Picks best match regardless of nozzle
+  });
+
+  it('handles dual-nozzle multi-slot mapping', () => {
+    // Two filaments: one for left, one for right
+    const reqs = {
+      filaments: [
+        { slot_id: 1, type: 'PLA', color: '#FF0000', used_grams: 10, nozzle_id: 1 },  // Left
+        { slot_id: 2, type: 'PETG', color: '#00FF00', used_grams: 10, nozzle_id: 0 }, // Right
+      ],
+    };
+    const status = createPrinterStatus([
+      {
+        id: 0,  // Left nozzle
+        tray: [
+          { id: 0, tray_type: 'PLA', tray_color: 'FF0000' },
+        ],
+      },
+      {
+        id: 1,  // Right nozzle
+        tray: [
+          { id: 0, tray_type: 'PETG', tray_color: '00FF00' },
+        ],
+      },
+    ]);
+    (status as any).ams_extruder_map = { '0': 1, '1': 0 };
+
+    const result = computeAmsMapping(reqs, status);
+
+    expect(result).toEqual([0, 4]);  // Left gets AMS0-T0, Right gets AMS1-T0
+  });
+});

+ 13 - 2
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,4 +1,5 @@
 import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
 import { api } from '../../api/client';
@@ -17,6 +18,7 @@ export function FilamentMapping({
   onManualMappingChange,
   defaultExpanded = false,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [isRefreshing, setIsRefreshing] = useState(false);
   const [isExpanded, setIsExpanded] = useState(defaultExpanded);
@@ -32,6 +34,7 @@ export function FilamentMapping({
     useFilamentMapping(filamentReqs, printerStatus, manualMappings);
 
   const hasFilamentReqs = filamentReqs?.filaments && filamentReqs.filaments.length > 0;
+  const isDualNozzle = filamentReqs?.filaments?.some((f) => f.nozzle_id != null) ?? false;
 
   // Don't render if no filament requirements
   if (!hasFilamentReqs) {
@@ -126,8 +129,16 @@ export function FilamentMapping({
               <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
                 <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
               </span>
-              {/* Required type + grams */}
-              <span className="text-white truncate">
+              {/* Required type + grams + nozzle badge */}
+              <span className="text-white truncate flex items-center gap-1">
+                {isDualNozzle && item.nozzle_id != null && (
+                  <span
+                    className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
+                    title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
+                  >
+                    {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
+                  </span>
+                )}
                 {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
               </span>
               {/* Arrow */}

+ 1 - 0
frontend/src/components/PrintModal/types.ts

@@ -158,6 +158,7 @@ export interface FilamentReqsData {
     color: string;
     used_grams: number;
     used_meters: number;
+    nozzle_id?: number;
   }>;
 }
 

+ 27 - 44
frontend/src/hooks/useFilamentMapping.ts

@@ -15,6 +15,8 @@ import type { PrinterStatus } from '../api/client';
  */
 export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined): LoadedFilament[] {
   const filaments: LoadedFilament[] = [];
+  const amsExtruderMap = printerStatus?.ams_extruder_map;
+  const hasDualNozzle = amsExtruderMap && Object.keys(amsExtruderMap).length > 0;
 
   // Add filaments from all AMS units (regular and HT)
   printerStatus?.ams?.forEach((amsUnit) => {
@@ -33,6 +35,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
           label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
           globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
           trayInfoIdx: tray.tray_info_idx || '',
+          extruderId: amsExtruderMap?.[String(amsUnit.id)],
         });
       }
     });
@@ -52,6 +55,7 @@ export function buildLoadedFilaments(printerStatus: PrinterStatus | undefined):
       label: 'External',
       globalTrayId: 254,
       trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
+      extruderId: hasDualNozzle ? 0 : undefined,
     });
   }
 
@@ -90,7 +94,15 @@ export function computeAmsMapping(
     const reqTrayInfoIdx = req.tray_info_idx || '';
 
     // Get available trays (not already used)
-    const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+    let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+    // Nozzle-aware filtering: restrict to trays on the correct nozzle
+    if (req.nozzle_id != null) {
+      const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
+      if (nozzleFiltered.length > 0) {
+        available = nozzleFiltered;
+      }
+    }
 
     let idxMatch: LoadedFilament | undefined;
     let exactMatch: LoadedFilament | undefined;
@@ -191,6 +203,8 @@ export interface LoadedFilament {
   globalTrayId: number;
   /** Unique spool identifier (e.g., "GFA00", "P4d64437") */
   trayInfoIdx?: string;
+  /** Extruder ID for dual-nozzle printers (0=right, 1=left) */
+  extruderId?: number;
 }
 
 /**
@@ -203,6 +217,8 @@ export interface FilamentRequirement {
   used_grams: number;
   /** Unique spool identifier from slicing (e.g., "GFA00", "P4d64437") */
   tray_info_idx?: string;
+  /** Target nozzle for dual-nozzle printers (0=right, 1=left) */
+  nozzle_id?: number;
 }
 
 /**
@@ -247,48 +263,7 @@ export function useLoadedFilaments(
   printerStatus: PrinterStatus | undefined
 ): LoadedFilament[] {
   return useMemo(() => {
-    const filaments: LoadedFilament[] = [];
-
-    // Add filaments from all AMS units (regular and HT)
-    printerStatus?.ams?.forEach((amsUnit) => {
-      const isHt = amsUnit.tray.length === 1; // AMS-HT has single tray
-      amsUnit.tray.forEach((tray) => {
-        if (tray.tray_type) {
-          const color = normalizeColor(tray.tray_color);
-          filaments.push({
-            type: tray.tray_type,
-            color,
-            colorName: getColorName(color),
-            amsId: amsUnit.id,
-            trayId: tray.id,
-            isHt,
-            isExternal: false,
-            label: formatSlotLabel(amsUnit.id, tray.id, isHt, false),
-            globalTrayId: getGlobalTrayId(amsUnit.id, tray.id, false),
-            trayInfoIdx: tray.tray_info_idx || '',
-          });
-        }
-      });
-    });
-
-    // Add external spool if loaded
-    if (printerStatus?.vt_tray?.tray_type) {
-      const color = normalizeColor(printerStatus.vt_tray.tray_color);
-      filaments.push({
-        type: printerStatus.vt_tray.tray_type,
-        color,
-        colorName: getColorName(color),
-        amsId: -1,
-        trayId: 0,
-        isHt: false,
-        isExternal: true,
-        label: 'External',
-        globalTrayId: 254,
-        trayInfoIdx: printerStatus.vt_tray.tray_info_idx || '',
-      });
-    }
-
-    return filaments;
+    return buildLoadedFilaments(printerStatus);
   }, [printerStatus]);
 }
 
@@ -355,7 +330,15 @@ export function useFilamentMapping(
       const reqTrayInfoIdx = req.tray_info_idx || '';
 
       // Get available trays (not already used)
-      const available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+      let available = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+
+      // Nozzle-aware filtering: restrict to trays on the correct nozzle
+      if (req.nozzle_id != null) {
+        const nozzleFiltered = available.filter((f) => f.extruderId === req.nozzle_id);
+        if (nozzleFiltered.length > 0) {
+          available = nozzleFiltered;
+        }
+      }
 
       let idxMatch: LoadedFilament | undefined;
       let exactMatch: LoadedFilament | undefined;

+ 26 - 14
frontend/src/hooks/useMultiPrinterFilamentMapping.ts

@@ -124,26 +124,32 @@ function computeMatchDetails(
       }
     }
 
-    // Auto-match
-    const exactMatch = loadedFilaments.find(
+    // Auto-match with nozzle-aware filtering
+    let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+    if (req.nozzle_id != null) {
+      const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
+      if (nozzleFiltered.length > 0) {
+        candidates = nozzleFiltered;
+      }
+    }
+
+    const exactMatch = candidates.find(
       (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
         f.type?.toUpperCase() === req.type?.toUpperCase() &&
         normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
     );
     const similarMatch = exactMatch
       ? undefined
-      : loadedFilaments.find(
+      : candidates.find(
           (f) =>
-            !usedTrayIds.has(f.globalTrayId) &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
             colorsAreSimilar(f.color, req.color)
         );
     const typeOnlyMatch =
       exactMatch || similarMatch
         ? undefined
-        : loadedFilaments.find(
-            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        : candidates.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
           );
     const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
 
@@ -196,26 +202,32 @@ function computeMappingWithOverrides(
       continue;
     }
 
-    // Auto-match
-    const exactMatch = loadedFilaments.find(
+    // Auto-match with nozzle-aware filtering
+    let candidates = loadedFilaments.filter((f) => !usedTrayIds.has(f.globalTrayId));
+    if (req.nozzle_id != null) {
+      const nozzleFiltered = candidates.filter((f) => f.extruderId === req.nozzle_id);
+      if (nozzleFiltered.length > 0) {
+        candidates = nozzleFiltered;
+      }
+    }
+
+    const exactMatch = candidates.find(
       (f) =>
-        !usedTrayIds.has(f.globalTrayId) &&
         f.type?.toUpperCase() === req.type?.toUpperCase() &&
         normalizeColorForCompare(f.color) === normalizeColorForCompare(req.color)
     );
     const similarMatch = exactMatch
       ? undefined
-      : loadedFilaments.find(
+      : candidates.find(
           (f) =>
-            !usedTrayIds.has(f.globalTrayId) &&
             f.type?.toUpperCase() === req.type?.toUpperCase() &&
             colorsAreSimilar(f.color, req.color)
         );
     const typeOnlyMatch =
       exactMatch || similarMatch
         ? undefined
-        : loadedFilaments.find(
-            (f) => !usedTrayIds.has(f.globalTrayId) && f.type?.toUpperCase() === req.type?.toUpperCase()
+        : candidates.find(
+            (f) => f.type?.toUpperCase() === req.type?.toUpperCase()
           );
     const loaded = exactMatch ?? similarMatch ?? typeOnlyMatch;
 

+ 10 - 0
frontend/src/i18n/locales/de.ts

@@ -124,6 +124,7 @@ export default {
     nozzleCount: 'Düsenanzahl',
     autoArchive: 'Automatische Archivierung',
     status: {
+      available: 'Verfügbar',
       idle: 'Bereit',
       printing: 'Druckt',
       paused: 'Pausiert',
@@ -163,6 +164,7 @@ export default {
     },
     // Controls
     hideOffline: 'Offline ausblenden',
+    nextAvailable: 'Nächster verfügbar',
     powerOn: 'Einschalten',
     offlinePrintersWithPlugs: 'Offline-Drucker mit Smart-Plugs',
     noPrintersConfigured: 'Noch keine Drucker konfiguriert',
@@ -980,6 +982,7 @@ export default {
     removeFromPrinter: 'Von diesem Drucker entfernen',
     // Types
     types: {
+      lubricateCarbonRods: 'Karbonstäbe schmieren',
       lubricateRails: 'Linearschienen schmieren',
       cleanNozzle: 'Düse/Hotend reinigen',
       checkBelts: 'Riemenspannung prüfen',
@@ -988,6 +991,7 @@ export default {
       checkCooling: 'Kühlungslüfter prüfen',
       generalInspection: 'Allgemeine Inspektion',
       cleanCarbonRods: 'Kohlenstoffstangen reinigen',
+      cleanLinearRails: 'Linearschienen reinigen',
       checkPtfeTube: 'PTFE-Schlauch prüfen',
       replaceHepaFilter: 'HEPA-Filter ersetzen',
       replaceCarbonFilter: 'Aktivkohlefilter ersetzen',
@@ -2632,6 +2636,10 @@ export default {
     sameTypeDifferentColor: 'Gleicher Typ, andere Farbe',
     filamentTypeNotLoaded: 'Filamenttyp nicht geladen',
     openCalendar: 'Kalender öffnen',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Linke Düse',
+    rightNozzleTooltip: 'Rechte Düse',
   },
 
   // Backup
@@ -2973,6 +2981,7 @@ export default {
 
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'Schmiermittel auf Karbonstäbe für sanfte Bewegung auftragen',
     lubricateRails: 'Schmiermittel auf Linearschienen für sanfte Bewegung auftragen',
     cleanNozzle: 'Hotend und Düse reinigen, um Verstopfungen zu verhindern',
     checkBelts: 'Riemenspannung für präzise Drucke überprüfen',
@@ -2981,6 +2990,7 @@ export default {
     checkCooling: 'Sicherstellen, dass Lüfter ordnungsgemäß funktionieren',
     generalInspection: 'Allgemeine Druckerinspektion',
     cleanCarbonRods: 'Karbonstäbe reinigen, um Reibung zu reduzieren',
+    cleanLinearRails: 'Linearschienen abwischen, um Staub und Schmutz zu entfernen',
     checkPtfeTube: 'PTFE-Schlauch auf Verschleiß oder Beschädigung prüfen',
     replaceHepaFilter: 'HEPA-Filter für Luftqualität ersetzen',
     replaceCarbonFilter: 'Aktivkohlefilter ersetzen',

+ 10 - 0
frontend/src/i18n/locales/en.ts

@@ -124,6 +124,7 @@ export default {
     nozzleCount: 'Nozzle Count',
     autoArchive: 'Auto Archive',
     status: {
+      available: 'Available',
       idle: 'Idle',
       printing: 'Printing',
       paused: 'Paused',
@@ -163,6 +164,7 @@ export default {
     },
     // Controls
     hideOffline: 'Hide offline',
+    nextAvailable: 'Next available',
     powerOn: 'Power On',
     offlinePrintersWithPlugs: 'Offline printers with smart plugs',
     noPrintersConfigured: 'No printers configured yet',
@@ -980,6 +982,7 @@ export default {
     removeFromPrinter: 'Remove from this printer',
     // Types
     types: {
+      lubricateCarbonRods: 'Lubricate Carbon Rods',
       lubricateRails: 'Lubricate Linear Rails',
       cleanNozzle: 'Clean Nozzle/Hotend',
       checkBelts: 'Check Belt Tension',
@@ -988,6 +991,7 @@ export default {
       checkCooling: 'Check Cooling Fans',
       generalInspection: 'General Inspection',
       cleanCarbonRods: 'Clean Carbon Rods',
+      cleanLinearRails: 'Clean Linear Rails',
       checkPtfeTube: 'Check PTFE Tube',
       replaceHepaFilter: 'Replace HEPA Filter',
       replaceCarbonFilter: 'Replace Carbon Filter',
@@ -2636,6 +2640,10 @@ export default {
     sameTypeDifferentColor: 'Same type, different color',
     filamentTypeNotLoaded: 'Filament type not loaded',
     openCalendar: 'Open calendar',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Left nozzle',
+    rightNozzleTooltip: 'Right nozzle',
   },
 
   // Backup
@@ -2978,6 +2986,7 @@ export default {
 
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'Apply lubricant to carbon rods for smooth motion',
     lubricateRails: 'Apply lubricant to linear rails for smooth motion',
     cleanNozzle: 'Clean hotend and nozzle to prevent clogs',
     checkBelts: 'Verify belt tension for accurate prints',
@@ -2986,6 +2995,7 @@ export default {
     checkCooling: 'Ensure cooling fans are working properly',
     generalInspection: 'General printer inspection',
     cleanCarbonRods: 'Clean carbon rods to reduce friction',
+    cleanLinearRails: 'Wipe linear rails to remove dust and debris',
     checkPtfeTube: 'Inspect PTFE tube for wear or damage',
     replaceHepaFilter: 'Replace HEPA filter for air quality',
     replaceCarbonFilter: 'Replace activated carbon filter',

+ 10 - 0
frontend/src/i18n/locales/it.ts

@@ -121,6 +121,7 @@ export default {
     nozzleCount: 'Numero Ugelli',
     autoArchive: 'Auto Archiviazione',
     status: {
+      available: 'Disponibile',
       idle: 'Inattiva',
       printing: 'In stampa',
       paused: 'In pausa',
@@ -160,6 +161,7 @@ export default {
     },
     // Controls
     hideOffline: 'Nascondi offline',
+    nextAvailable: 'Prossima disponibile',
     powerOn: 'Accendi',
     offlinePrintersWithPlugs: 'Stampanti offline con smart plug',
     noPrintersConfigured: 'Nessuna stampante configurata',
@@ -967,6 +969,7 @@ export default {
     removeFromPrinter: 'Rimuovi da questa stampante',
     // Types
     types: {
+      lubricateCarbonRods: 'Lubrifica aste in carbonio',
       lubricateRails: 'Lubrifica guide lineari',
       cleanNozzle: 'Pulisci ugello/Hotend',
       checkBelts: 'Controlla tensione cinghie',
@@ -975,6 +978,7 @@ export default {
       checkCooling: 'Controlla ventole raffreddamento',
       generalInspection: 'Ispezione generale',
       cleanCarbonRods: 'Pulisci aste in carbonio',
+      cleanLinearRails: 'Pulisci guide lineari',
       checkPtfeTube: 'Controlla tubo PTFE',
       replaceHepaFilter: 'Sostituisci filtro HEPA',
       replaceCarbonFilter: 'Sostituisci filtro carbone',
@@ -2306,6 +2310,10 @@ export default {
     noPrintersAvailable: 'Nessuna stampante disponibile',
     printerBusy: 'Stampante occupata',
     printerOffline: 'Stampante offline',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Ugello sinistro',
+    rightNozzleTooltip: 'Ugello destro',
   },
 
   // Backup
@@ -2647,6 +2655,7 @@ export default {
 
   // Maintenance type descriptions (built-in)
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'Applica lubrificante alle aste in carbonio per un movimento fluido',
     lubricateRails: 'Applica lubrificante alle guide lineari per un movimento fluido',
     cleanNozzle: 'Pulisci hotend e ugello per prevenire intasamenti',
     checkBelts: 'Verifica tensione cinghie per stampe accurate',
@@ -2655,6 +2664,7 @@ export default {
     checkCooling: 'Assicurati che le ventole di raffreddamento funzionino',
     generalInspection: 'Ispezione generale stampante',
     cleanCarbonRods: 'Pulisci le aste in carbonio per ridurre attrito',
+    cleanLinearRails: 'Pulisci le guide lineari per rimuovere polvere e detriti',
     checkPtfeTube: 'Ispeziona il tubo PTFE per usura o danni',
     replaceHepaFilter: 'Sostituisci filtro HEPA per qualità aria',
     replaceCarbonFilter: 'Sostituisci filtro a carbone attivo',

+ 10 - 0
frontend/src/i18n/locales/ja.ts

@@ -133,6 +133,7 @@ export default {
     nozzleCount: 'ノズル数',
     autoArchive: '自動アーカイブ',
     status: {
+      available: '利用可能',
       idle: '待機中',
       printing: '印刷中',
       paused: '一時停止',
@@ -167,6 +168,7 @@ export default {
       extraLarge: '特大',
     },
     hideOffline: 'オフラインを非表示',
+    nextAvailable: '次に完了',
     powerOn: '電源オン',
     noPrintersConfigured: 'プリンターが設定されていません',
     readyToPrint: '印刷可能',
@@ -1015,6 +1017,7 @@ export default {
     noPrintersAssigned: 'プリンター未割り当て',
     removeFromPrinter: 'このプリンターから削除',
     types: {
+      lubricateCarbonRods: 'カーボンロッドの潤滑',
       lubricateRails: 'リニアレールの潤滑',
       cleanNozzle: 'ノズル/ホットエンドの清掃',
       checkBelts: 'ベルト張力の確認',
@@ -1023,6 +1026,7 @@ export default {
       checkCooling: '冷却ファンの確認',
       generalInspection: '総合点検',
       cleanCarbonRods: 'カーボンロッドの清掃',
+      cleanLinearRails: 'リニアレールの清掃',
       checkPtfeTube: 'PTFEチューブの確認',
       replaceHepaFilter: 'HEPAフィルター交換',
       replaceCarbonFilter: 'カーボンフィルター交換',
@@ -2552,6 +2556,10 @@ export default {
     printerBusy: 'プリンターは使用中です',
     printerOffline: 'プリンターはオフラインです',
     cancel: 'キャンセル',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: '左ノズル',
+    rightNozzleTooltip: '右ノズル',
   },
   backup: {
     restoreBackup: 'バックアップの復元',
@@ -2856,6 +2864,7 @@ export default {
     },
   },
   maintenanceDescriptions: {
+    lubricateCarbonRods: 'カーボンロッドに潤滑剤を塗布してスムーズな動きを確保',
     lubricateRails: 'リニアレールの潤滑',
     cleanNozzle: 'ノズル/ホットエンドの清掃',
     checkBelts: 'ベルト張力の確認',
@@ -2864,6 +2873,7 @@ export default {
     checkCooling: '冷却ファンの確認',
     generalInspection: '総合点検',
     cleanCarbonRods: 'カーボンロッドの清掃',
+    cleanLinearRails: 'リニアレールを拭いてほこりや汚れを除去',
     checkPtfeTube: 'PTFEチューブの確認',
     replaceHepaFilter: 'HEPAフィルター交換',
     replaceCarbonFilter: 'カーボンフィルター交換',

+ 19 - 9
frontend/src/pages/MaintenancePage.tsx

@@ -140,14 +140,19 @@ function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): s
   const isP2S = model.includes('P2S');
 
   switch (typeName) {
-    case 'Lubricate Linear Rails':
+    case 'Lubricate Carbon Rods':
+      // X1, P1, P2S series have carbon rods
       if (isX1) return 'https://wiki.bambulab.com/en/x1/maintenance/basic-maintenance';
       if (isP1) return 'https://wiki.bambulab.com/en/p1/maintenance/p1p-maintenance';
+      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension';
+      return null;
+
+    case 'Lubricate Linear Rails':
+      // A1 and H2 series have linear rails
       if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
       if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
       if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
-      if (isP2S) return 'https://wiki.bambulab.com/en/p2s/maintenance/belt-tension'; // P2S maintenance page
-      return 'https://wiki.bambulab.com/en/general/lead-screws-lubrication';
+      return null;
 
     case 'Clean Nozzle/Hotend':
       if (isX1 || isP1) return 'https://wiki.bambulab.com/en/x1/troubleshooting/nozzle-clog';
@@ -168,11 +173,16 @@ function getMaintenanceWikiUrl(typeName: string, printerModel: string | null): s
       return 'https://wiki.bambulab.com/en/x1/maintenance/belt-tension';
 
     case 'Clean Carbon Rods':
-      // Only X1 and P1 series have carbon rods
-      if (isX1 || isP1) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
-      // A1, H2, P2S don't have carbon rods - return null
-      if (isA1Mini || isA1 || isH2 || isP2S) return null;
-      return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+      // X1, P1, P2S series have carbon rods
+      if (isX1 || isP1 || isP2S) return 'https://wiki.bambulab.com/en/general/carbon-rods-clearance';
+      return null;
+
+    case 'Clean Linear Rails':
+      // A1 and H2 series have linear rails
+      if (isA1Mini) return 'https://wiki.bambulab.com/en/a1-mini/maintenance/lubricate-y-axis';
+      if (isA1) return 'https://wiki.bambulab.com/en/a1/maintenance/lubricate-y-axis';
+      if (isH2) return 'https://wiki.bambulab.com/en/h2/maintenance/x-axis-lubrication';
+      return null;
 
     case 'Clean Build Plate':
       // Same for all printers
@@ -381,7 +391,7 @@ function PrinterSection({
   hasPermission: (permission: Permission) => boolean;
   t: TFunction;
 }) {
-  const [expanded, setExpanded] = useState(true);
+  const [expanded, setExpanded] = useState(false);
   const [editingHours, setEditingHours] = useState(false);
   const [hoursInput, setHoursInput] = useState(overview.total_print_hours.toFixed(1));
 

+ 62 - 37
frontend/src/pages/PrintersPage.tsx

@@ -1137,21 +1137,17 @@ function getWifiStrength(rssi: number): { labelKey: string; color: string; bars:
 }
 
 /**
- * Check if a tray contains a Bambu Lab spool.
- * Uses same logic as backend: tray_info_idx (GF*), tray_uuid, or tag_uid.
+ * Check if a tray contains a Bambu Lab spool (RFID-tagged).
+ * Only checks hardware identifiers (tray_uuid, tag_uid) — NOT tray_info_idx,
+ * which is a filament profile/preset ID that third-party spools also get when
+ * the user selects a generic Bambu preset (e.g. "GFA00" for Generic PLA).
  */
 function isBambuLabSpool(tray: {
   tray_uuid?: string | null;
   tag_uid?: string | null;
-  tray_info_idx?: string | null;
 } | null | undefined): boolean {
   if (!tray) return false;
 
-  // Check tray_info_idx first (most reliable - Bambu preset IDs start with "GF")
-  if (tray.tray_info_idx && tray.tray_info_idx.startsWith('GF')) {
-    return true;
-  }
-
   // Check tray_uuid (32 hex chars, non-zero)
   if (tray.tray_uuid && tray.tray_uuid !== '00000000000000000000000000000000') {
     return true;
@@ -1240,14 +1236,34 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
 
-  const counts = useMemo(() => {
+  // Subscribe to query cache changes to re-render when status updates
+  // Throttled to prevent rapid re-renders from causing tab crashes
+  const [cacheTick, setCacheTick] = useState(0);
+  useEffect(() => {
+    let pending = false;
+    const unsubscribe = queryClient.getQueryCache().subscribe(() => {
+      if (!pending) {
+        pending = true;
+        requestAnimationFrame(() => {
+          setCacheTick(t => t + 1);
+          pending = false;
+        });
+      }
+    });
+    return () => unsubscribe();
+  }, [queryClient]);
+
+  const { counts, nextFinish } = useMemo(() => {
     let printing = 0;
     let idle = 0;
     let offline = 0;
     let loading = 0;
+    let nextPrinterName: string | null = null;
+    let nextRemainingMin: number | null = null;
+    let nextProgress: number = 0;
 
     printers?.forEach((printer) => {
-      const status = queryClient.getQueryData<{ connected: boolean; state: string | null }>(['printerStatus', printer.id]);
+      const status = queryClient.getQueryData<{ connected: boolean; state: string | null; remaining_time: number | null; progress: number | null }>(['printerStatus', printer.id]);
       if (status === undefined) {
         // Status not yet loaded - don't count as offline yet
         loading++;
@@ -1255,35 +1271,35 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
         offline++;
       } else if (status.state === 'RUNNING') {
         printing++;
+        if (status.remaining_time != null && status.remaining_time > 0) {
+          if (nextRemainingMin === null || status.remaining_time < nextRemainingMin) {
+            nextRemainingMin = status.remaining_time;
+            nextPrinterName = printer.name;
+            nextProgress = status.progress || 0;
+          }
+        }
       } else {
         idle++;
       }
     });
 
-    return { printing, idle, offline, loading, total: (printers?.length || 0) };
-  }, [printers, queryClient]);
-
-  // Subscribe to query cache changes to re-render when status updates
-  // Throttled to prevent rapid re-renders from causing tab crashes
-  const [, setTick] = useState(0);
-  useEffect(() => {
-    let pending = false;
-    const unsubscribe = queryClient.getQueryCache().subscribe(() => {
-      if (!pending) {
-        pending = true;
-        requestAnimationFrame(() => {
-          setTick(t => t + 1);
-          pending = false;
-        });
-      }
-    });
-    return () => unsubscribe();
-  }, [queryClient]);
+    return {
+      counts: { printing, idle, offline, loading, total: (printers?.length || 0) },
+      nextFinish: nextPrinterName && nextRemainingMin ? { name: nextPrinterName, remainingMin: nextRemainingMin, progress: nextProgress } : null,
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [printers, queryClient, cacheTick]);
 
   if (!printers?.length) return null;
 
   return (
     <div className="flex items-center gap-4 text-sm">
+      <div className="flex items-center gap-1.5">
+        <div className={`w-2 h-2 rounded-full ${counts.idle > 0 ? 'bg-bambu-green' : 'bg-gray-500'}`} />
+        <span className="text-bambu-gray">
+          <span className="text-white font-medium">{counts.idle}</span> {t('printers.status.available').toLowerCase()}
+        </span>
+      </div>
       {counts.printing > 0 && (
         <div className="flex items-center gap-1.5">
           <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
@@ -1292,14 +1308,6 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
           </span>
         </div>
       )}
-      {counts.idle > 0 && (
-        <div className="flex items-center gap-1.5">
-          <div className="w-2 h-2 rounded-full bg-blue-400" />
-          <span className="text-bambu-gray">
-            <span className="text-white font-medium">{counts.idle}</span> {t('printers.status.idle').toLowerCase()}
-          </span>
-        </div>
-      )}
       {counts.offline > 0 && (
         <div className="flex items-center gap-1.5">
           <div className="w-2 h-2 rounded-full bg-gray-400" />
@@ -1308,6 +1316,23 @@ function StatusSummaryBar({ printers }: { printers: Printer[] | undefined }) {
           </span>
         </div>
       )}
+      {nextFinish && (
+        <>
+          <div className="w-px h-4 bg-bambu-dark-tertiary" />
+          <div className="flex items-center gap-2">
+            <span className="text-bambu-green font-medium">{t('printers.nextAvailable')}:</span>
+            <span className="text-white font-medium">{nextFinish.name}</span>
+            <div className="w-16 bg-bambu-dark-tertiary rounded-full h-1.5">
+              <div
+                className="bg-bambu-green h-1.5 rounded-full transition-all"
+                style={{ width: `${nextFinish.progress}%` }}
+              />
+            </div>
+            <span className="text-white font-medium">{Math.round(nextFinish.progress)}%</span>
+            <span className="text-bambu-gray">({formatTime(nextFinish.remainingMin * 60)})</span>
+          </div>
+        </>
+      )}
     </div>
   );
 }

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DIy2OgxD.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CYXpThrD.js"></script>
+    <script type="module" crossorigin src="/assets/index-DIy2OgxD.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DMk3iz3Q.css">
   </head>
   <body>

+ 6 - 6
test_backend.sh

@@ -3,9 +3,9 @@
 cd backend
 ruff check && ruff format --check
 
-if [ "$1" = "--full" ]; then
-  ../venv/bin/python3 -m pytest tests/ -v -n 14
-else
-  ../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py
-fi
-cd ..
+#if [ "$1" = "--full" ]; then
+#  ../venv/bin/python3 -m pytest tests/ -v -n 14
+#else
+../venv/bin/python3 -m pytest tests/ -v -n 14 --ignore=tests/unit/services/test_bambu_ftp.py
+#fi
+#cd ..

Some files were not shown because too many files changed in this diff