Browse Source

Merge pull request #1435 from maziggy/0.2.4.2

  **Bambuddy 0.2.4.2 — Release Notes**

  **A note on the 0.2.4 train going forward**

I've decided not to add any further features to the 0.2.4. train.* Everything between now and 0.2.5 will be bug fixes, security patches, and stability work only. The goal is to get this train as rock-solid as it can be before the next feature wave lands in 0.2.5. New features that arrive in the meantime queue up against the 0.2.5 milestone — not 0.2.4.x.

(The handful of feat: items below were already merged into the 0.2.4.x branch before this decision and are shipping with the release. Starting now, only fix:, security:, and chore: go in.)

  ---
  **Highlights**

- FTP reliability: two long-standing flakes finally pinned down — the silent-success-on-426 upload bug (#1401) and the transient-426-on-already-uploaded-file (#1417 follow-up). Combined, these were the largest single source of "print sometimes won't start" reports.

- Virtual Printer queue / immediate / review modes: AMS no longer flickers or disappears in BambuStudio against P1S/A1 targets between pushalls (#1387). And VP-queue dispatched prints now inherit timelapse/bed-leveling/flow-cali/vibration-cali/layer-inspect from the slicer instead of always reverting to defaults (#1403).

- Stats page: the post-0.2.4.1 stats rewrite left Filament Used / By Time / Success Rate misaligned with Total Consumed and Total Prints (#1390). Fixed, plus the pre-upgrade Filament Cost = 0 / empty Time Accuracy regression in Quick Stats.

- H2S without AMS: the H2S was misclassified as dual-nozzle and refused to start prints when no AMS was attached (#1386). Fixed.

- Spoolman parity: editing a spool no longer mints duplicate filaments in the Spoolman catalogue (#1357 follow-up); Color Name edits now persist (Spoolman has no filament.color_name, so it lives in spool.extra now); "Reset usage to 0" and "Print labels…" both work in Spoolman mode; Settings → Filament now shows the same Spool Catalog UI in both modes.

- Self-signed CA support (Docker): opt-in via USE_SYSTEM_TRUST_STORE=1 + mounting your CA into /usr/local/share/ca-certificates. The Debian system bundle stays alongside your CA — so api.github.com, MakerWorld, and Bambu Cloud keep working. Default-off, fail-fast on misconfig. (#1431, contributed by @WizBangCrash, requested in #1289)

- Security: urllib3 bumped to clear CVE-2026-44431 / CVE-2026-44432; ws dev-dep bumped (#1433); GitHub backup now refuses to save against a non-private repository.

  ---
  **Added**

- Docker: opt-in system trust store for self-signed CAs (#1431, @WizBangCrash, req. in #1289)

- Inventory: Storage Location filter chip (#1400, req. by @pgladel)

- Inventory: "Reset usage to 0" — per-spool and across all active spools, in both internal and Spoolman modes (#1390 follow-up, req. by @IndividualGhost1905)

- Inventory: spool ID surfaced in edit modal and AMS hover card (#1385, contributed by @chanakyan-arivumani in #1402, reported by @pgladel)

- Print labels: sort by colour as an alternative to spool-ID order (#1410, req. by @elit3ge)

- Smart plugs: auto-off after AMS drying completes (#1349, req. by @Kyobinoyo)

- Camera: in-app diagnostic for "Connection lost" (#1395 follow-up)

  **Changed**

- AMS Filament Label Holder presets fixed and split into "small" and "large" variants (#1426, @bsaunder). The incorrect 30×15 mm preset is replaced.

- Archives → Print Log: filename column expands to fit available width and wraps long names instead of clipping at 200 px (#1406, req. by @daFreeMan)

- Bulk & scheduled archive purge now honour the soft / hard delete choice that single-archive delete already exposes (#1390 follow-up)

- Cloud login: corrected the access-token hint to reflect that Bambu Lab no longer surfaces the token in any UI; called out the China-region MakerWorld cookie path explicitly (#1396)

- Settings → Filament: Spool Catalog now shows the same UI in Spoolman mode as in internal-inventory mode

- GitHub backup: save-failure messages render inline on the card instead of as a toast

  **Fixed**

  **FTP / upload**

- FTP upload no longer silently treats 426 Failure reading network stream as success (#1401 root cause #2, @iitazz)

- FTP: tolerate transient 426 when the file is actually intact on the SD card (#1417 follow-up, @enjoylifenow)

- Upload validation rejects unprintable 3MF / raw-gcode files at the upload step instead of letting them fail at the printer (#1401, @iitazz)

  **Virtual Printer**

- AMS data flickering / disappearing in BambuStudio between pushalls on P1S/A1 targets (#1387)

- VP-queue dispatched prints inherit timelapse / bed-leveling / flow-cali / vibration-cali / layer-inspect from the slicer (#1403, @pwostran)

- Archives: "Scan for timelapse" no longer permanently disabled on VP-queue-dispatched prints (#1403 follow-up, @pwostran, @enjoylifenow)

  **Inventory / Spoolman**

- Spoolman edit-spool no longer mints duplicate filaments in the catalogue (#1357 follow-up, @pgladel)

- Spoolman: Color Name edits now persist via spool.extra (#1357)

- "Reset usage to 0" no longer inflates remaining weight back to label_weight (#1390 follow-up, @IndividualGhost1905)

- "Total Consumed" now includes archived spools' usage, and the eraser works on archived too (#1390 follow-up, @IndividualGhost1905)

- Add Spool modal: hex colour field can be typed into character-by-character again (#1407, @anthonyma94)

  **Stats**

- Filament Used / By Time / Success Rate now agree with Total Consumed and Total Prints after the 0.2.4.1 stats rewrite (#1390 follow-up, @IndividualGhost1905)

- Backfilled PrintLogEntry.cost / energy / archive_id for pre-#1378 rows so Quick Stats no longer shows Filament Cost = 0 / empty Time Accuracy (#1390)

- Per-event data now powers every widget, not just Quick Stats (#1390)

  **Camera / printer / AMS**

- P2S camera: relaxed ffmpeg probe so the RTSP stream actually locks (#1395 follow-up, @Tschipel)

- Per-model camera profile registry (#1395)

- AMS physically-empty slots now consistently report state=9 and render distinctly from reset slots (#1322 follow-up, @RosdasHH)

- H2S without AMS could not start prints — was misclassified as dual-nozzle (#1386)

- Adding a printer with a wrong access code (or unreachable IP) no longer creates an empty card

- Assign Spool: printer card refreshes immediately without needing Force-refresh (#1414 follow-up, @snozzlebert)

- VP cache: deep-merge AMS on bridge cache so P1S/A1 partial pushes don't nuke AMS (#1387)

  **SpoolBuddy**

- NFC reader works again on Raspberry Pi 5 — tolerate SPI_NO_CS rejection (#1424, @flom89)

  **Other**

- Library "Open in Slicer": works when the display name lacks .3mf or contains / \ ? # (#1413, contributed by @benhalverson in #1416, reported by @ddingg)

- Cover thumbnails: stop hammering FTP and GitHub when a print's 3MF isn't on the printer; negative-cache covers 404s, GitHub rate-limit backoff added (#1420)

- Archives: assign printer_id when reusing VP-queue archives in print-start (#1403 follow-up)

- Add Smart Plug (HA mode): entity search bypassed the schema's domain whitelist (#1388)

  **Security**

- GitHub backup refuses to save against a non-private repository

- urllib3 pinned to >=2.7.0 to clear CVE-2026-44431 / CVE-2026-44432

- ws dev-dep bumped (#1433)

- verify=False suppressions in support.py switched to bandit nosec syntax for cleaner static-analysis output

  ---
  **Upgrade notes**

- Container image: the Dockerfile now installs ca-certificates (~250 KB). No behaviour change unless you opt into USE_SYSTEM_TRUST_STORE.

- Database: no schema migrations required from 0.2.4.1.

- Compose template: the shipped template now contains a commented-out example block for the self-signed CA mount + env var. Existing compose files are unaffected.

  **Thanks**

Reporters and contributors who made this release: @WizBangCrash, @benhalverson, @chanakyan-arivumani, @IndividualGhost1905, @pgladel, @iitazz, @enjoylifenow, @pwostran, @Tschipel, @RosdasHH, @anthonyma94, @snozzlebert, @daFreeMan, @bsaunder, @elit3ge, @Kyobinoyo, @flom89, @ddingg, @anthonyma94. Plus everyone who reported the empty-card / wrong-access-code class of issues that didn't get a single issue number.
MartinNYHC 1 week ago
parent
commit
798a2c8770
100 changed files with 6988 additions and 1603 deletions
  1. 3 0
      CHANGELOG.md
  2. 1 0
      Dockerfile
  3. 42 13
      backend/app/api/routes/_spoolman_helpers.py
  4. 28 7
      backend/app/api/routes/archive_purge.py
  5. 69 26
      backend/app/api/routes/archives.py
  6. 49 14
      backend/app/api/routes/camera.py
  7. 57 0
      backend/app/api/routes/github_backup.py
  8. 54 0
      backend/app/api/routes/inventory.py
  9. 12 9
      backend/app/api/routes/kprofiles.py
  10. 10 2
      backend/app/api/routes/labels.py
  11. 59 2
      backend/app/api/routes/library.py
  12. 48 5
      backend/app/api/routes/printers.py
  13. 141 17
      backend/app/api/routes/spoolman_inventory.py
  14. 96 0
      backend/app/api/routes/updates.py
  15. 17 19
      backend/app/api/routes/webhook.py
  16. 1 1
      backend/app/core/config.py
  17. 96 0
      backend/app/core/database.py
  18. 47 11
      backend/app/main.py
  19. 9 0
      backend/app/models/smart_plug.py
  20. 6 0
      backend/app/models/spool.py
  21. 9 0
      backend/app/schemas/archive_purge.py
  22. 5 0
      backend/app/schemas/github_backup.py
  23. 8 0
      backend/app/schemas/smart_plug.py
  24. 5 0
      backend/app/schemas/spool.py
  25. 96 24
      backend/app/services/archive_purge.py
  26. 2 2
      backend/app/services/background_dispatch.py
  27. 72 4
      backend/app/services/bambu_ftp.py
  28. 112 34
      backend/app/services/bambu_mqtt.py
  29. 288 0
      backend/app/services/camera_diagnose.py
  30. 111 0
      backend/app/services/camera_profiles.py
  31. 71 61
      backend/app/services/failure_analysis.py
  32. 4 0
      backend/app/services/git_providers/forgejo.py
  33. 4 0
      backend/app/services/git_providers/github.py
  34. 9 0
      backend/app/services/git_providers/gitlab.py
  35. 45 0
      backend/app/services/github_backup.py
  36. 19 12
      backend/app/services/homeassistant.py
  37. 32 17
      backend/app/services/label_renderer.py
  38. 14 0
      backend/app/services/printer_manager.py
  39. 39 0
      backend/app/services/smart_plug_manager.py
  40. 86 25
      backend/app/services/spoolman.py
  41. 79 6
      backend/app/services/virtual_printer/manager.py
  42. 120 2
      backend/app/services/virtual_printer/mqtt_bridge.py
  43. 3 0
      backend/tests/conftest.py
  44. 82 10
      backend/tests/integration/test_archive_purge_api.py
  45. 148 4
      backend/tests/integration/test_archives_api.py
  46. 50 0
      backend/tests/integration/test_camera_api.py
  47. 11 1
      backend/tests/integration/test_external_folders_api.py
  48. 216 0
      backend/tests/integration/test_github_backup_api.py
  49. 8 2
      backend/tests/integration/test_labels.py
  50. 110 0
      backend/tests/integration/test_library_api.py
  51. 100 0
      backend/tests/integration/test_printers_api.py
  52. 209 0
      backend/tests/integration/test_spool_reset_usage.py
  53. 197 30
      backend/tests/integration/test_spoolman_inventory_api.py
  54. 62 0
      backend/tests/integration/test_updates_api.py
  55. 130 0
      backend/tests/integration/test_webhook_start_print.py
  56. 39 0
      backend/tests/unit/services/test_background_dispatch.py
  57. 128 0
      backend/tests/unit/services/test_bambu_ftp.py
  58. 232 19
      backend/tests/unit/services/test_bambu_mqtt.py
  59. 281 0
      backend/tests/unit/services/test_camera_diagnose.py
  60. 90 0
      backend/tests/unit/services/test_camera_profiles.py
  61. 148 0
      backend/tests/unit/services/test_homeassistant_list_entities.py
  62. 17 9
      backend/tests/unit/services/test_label_renderer.py
  63. 95 0
      backend/tests/unit/services/test_smart_plug_manager.py
  64. 153 0
      backend/tests/unit/services/test_virtual_printer.py
  65. 247 0
      backend/tests/unit/test_print_log_backfill_migration.py
  66. 207 0
      backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py
  67. 11 9
      backend/tests/unit/test_run_filament_helper.py
  68. 80 0
      backend/tests/unit/test_spoolman_inventory_helpers.py
  69. 74 42
      backend/tests/unit/test_spoolman_inventory_methods.py
  70. 183 0
      backend/tests/unit/test_vp_mqtt_bridge.py
  71. 32 0
      deploy/docker-entrypoint.sh
  72. 11 0
      docker-compose.yml
  73. 8 7
      frontend/scripts/check-i18n-parity.mjs
  74. 16 0
      frontend/src/__tests__/api/client.test.ts
  75. 38 0
      frontend/src/__tests__/components/AssignSpoolModal.test.tsx
  76. 123 0
      frontend/src/__tests__/components/CameraDiagnoseModal.test.tsx
  77. 15 0
      frontend/src/__tests__/components/FilamentHoverCard.test.tsx
  78. 76 7
      frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx
  79. 12 417
      frontend/src/__tests__/components/SpoolCatalogSettings.test.tsx
  80. 64 0
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  81. 0 97
      frontend/src/__tests__/components/SpoolWeightUpdateModal.test.tsx
  82. 107 46
      frontend/src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx
  83. 209 0
      frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx
  84. 36 0
      frontend/src/__tests__/pages/PrintersPage.test.tsx
  85. 66 1
      frontend/src/__tests__/pages/StatsPage.test.tsx
  86. 99 9
      frontend/src/api/client.ts
  87. 16 0
      frontend/src/components/AssignSpoolModal.tsx
  88. 158 0
      frontend/src/components/CameraDiagnoseModal.tsx
  89. 36 8
      frontend/src/components/EmbeddedCameraViewer.tsx
  90. 15 7
      frontend/src/components/FilamentHoverCard.tsx
  91. 12 2
      frontend/src/components/FilamentSlotCircle.tsx
  92. 18 1
      frontend/src/components/FileUploadModal.tsx
  93. 7 3
      frontend/src/components/ForecastPanel.tsx
  94. 78 11
      frontend/src/components/GitHubBackupSettings.tsx
  95. 104 9
      frontend/src/components/LabelTemplatePickerModal.tsx
  96. 20 3
      frontend/src/components/PurgeArchivesModal.tsx
  97. 37 0
      frontend/src/components/SmartPlugCard.tsx
  98. 185 433
      frontend/src/components/SpoolCatalogSettings.tsx
  99. 4 1
      frontend/src/components/SpoolFormModal.tsx
  100. 0 102
      frontend/src/components/SpoolWeightUpdateModal.tsx

File diff suppressed because it is too large
+ 3 - 0
CHANGELOG.md


+ 1 - 0
Dockerfile

@@ -28,6 +28,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
     iproute2 \
     libcap2-bin \
     openssh-client \
+    ca-certificates \
     && rm -rf /var/lib/apt/lists/*
 
 # Install the Tailscale CLI only (no tailscaled — the daemon runs on the host).

+ 42 - 13
backend/app/api/routes/_spoolman_helpers.py

@@ -34,6 +34,7 @@ class MappedSpoolFields(TypedDict):
     core_weight: int | None
     core_weight_catalog_id: None
     weight_used: float | None
+    weight_used_baseline: float | None
     weight_locked: bool
     last_scale_weight: None
     last_weighed_at: None
@@ -247,7 +248,24 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
     rgba: str = color_hex + "FF"
 
     label_weight: int = _safe_int(filament.get("weight"), 1000)
-    used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
+    real_used_weight: float = _safe_float(spool.get("used_weight"), 0.0)
+    # Parity with internal mode (#1390): the InventorySpool shape lets the
+    # frontend compute `remaining = label_weight - weight_used` and
+    # `consumed = weight_used - weight_used_baseline`. Map Spoolman's two
+    # independent fields (used_weight, remaining_weight) onto that shape:
+    #   weight_used = label_weight - remaining_weight  (so remaining matches)
+    #   baseline    = weight_used - used_weight        (so consumed matches)
+    # When remaining_weight is unset (legacy spools, or filament linked but
+    # never primed), fall back to the old behaviour: weight_used =
+    # used_weight, baseline = 0.
+    remaining_raw = spool.get("remaining_weight")
+    if remaining_raw is not None:
+        remaining_weight: float = _safe_float(remaining_raw, 0.0)
+        used_weight: float = max(0.0, float(label_weight) - remaining_weight)
+        weight_used_baseline: float = max(0.0, used_weight - real_used_weight)
+    else:
+        used_weight = real_used_weight
+        weight_used_baseline = 0.0
 
     # Archived state – Spoolman uses a boolean ``archived`` field
     archived: bool = spool.get("archived", False)
@@ -257,18 +275,28 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
 
     created_at: str | None = spool.get("registered") or None
 
-    # Spoolman doesn't standardise a `color_name` field — most installs only
-    # populate `color_hex` (the swatch) and the filament's `name` (which often
-    # carries the colour, e.g. "PLA Basic Red"). Without a fallback the
-    # frontend lists a sea of "Unknown color" entries that all look identical
-    # except for the swatch. Fall back to the filament name minus material
-    # prefix (the same string the `subtype` field already carries — typically
-    # "Basic Red" / "PLA+ Black" / etc.) so the user can tell spools apart at
-    # a glance even on Spoolman installs that don't fill color_name.
-    # color_name_is_synthesized: surfaced so the edit form can avoid prefilling
-    # the synth value back into the input, which would otherwise round-trip the
-    # subtype string as if it were a user-set color_name (#1319).
-    stored_color_name = filament.get("color_name") or None
+    # Spoolman has no `color_name` field on Filament — confirmed against the
+    # FilamentUpdateParameters schema in 0.23.1: name/vendor_id/material/price/
+    # density/diameter/weight/spool_weight/article_number/comment/extruder_temp/
+    # bed_temp/color_hex/multi_color_hexes/multi_color_direction/external_id/
+    # extra, no color_name (#1357). The previous attempt (b8e350c3) was
+    # PATCHing a key Spoolman silently discards, which is why color_name
+    # never actually persisted from the user's edits.
+    #
+    # We persist it ourselves under spool.extra.bambu_color_name (JSON-encoded
+    # string, same pattern as bambu_slicer_filament). Read order:
+    #   1. spool.extra.bambu_color_name (the canonical store)
+    #   2. filament.color_name (forward-compat — picks up the value if a
+    #      future Spoolman release adds the field, or if an admin populated
+    #      it via a custom extra-field they registered themselves)
+    #   3. subtype (synth fallback so the inventory list isn't a sea of
+    #      "Unknown color" entries on installs with neither field set)
+    #
+    # color_name_is_synthesized = True only when we fell back to subtype.
+    # The edit form uses it to leave the input blank, so the user doesn't
+    # round-trip the synth value back as if they had set it.
+    extra_color_name = _extract_extra_str(extra, "bambu_color_name") or None
+    stored_color_name = extra_color_name or (filament.get("color_name") or None)
     color_name: str | None = stored_color_name or subtype or None
     color_name_is_synthesized: bool = stored_color_name is None and color_name is not None
 
@@ -289,6 +317,7 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         ),
         "core_weight_catalog_id": None,
         "weight_used": used_weight,
+        "weight_used_baseline": weight_used_baseline,
         "weight_locked": False,
         "last_scale_weight": None,
         "last_weighed_at": None,

+ 28 - 7
backend/app/api/routes/archive_purge.py

@@ -38,11 +38,21 @@ router = APIRouter(prefix="/archives", tags=["archives-purge"])
 @router.get("/purge/preview", response_model=ArchivePurgePreviewResponse)
 async def preview_archive_purge(
     older_than_days: int = Query(ge=1, le=3650),
+    purge_stats: bool = Query(
+        False,
+        description=(
+            "When False (default) the count reflects soft-delete mode — "
+            "already-soft-deleted rows are excluded so the number matches "
+            "what a fresh purge would actually touch. When True the count "
+            "includes already-soft-deleted rows (eligible for promotion to "
+            "hard-delete). #1390."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     """Count + size of archives eligible for purge. Read-only."""
-    result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days)
+    result = await archive_purge_service.preview_purge(db, older_than_days=older_than_days, purge_stats=purge_stats)
     return ArchivePurgePreviewResponse(**result)
 
 
@@ -52,9 +62,18 @@ async def execute_archive_purge(
     db: AsyncSession = Depends(get_db),
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
-    """Hard-delete archives older than the threshold. Irreversible."""
-    deleted = await archive_purge_service.purge_older_than(db, older_than_days=body.older_than_days)
-    return ArchivePurgeResponse(deleted=deleted)
+    """Bulk-delete archives older than the threshold.
+
+    Soft-delete by default (Quick Stats preserved). Set ``purge_stats=true``
+    in the body to also drop the contribution from /stats — irreversible
+    in that mode, same as the single-archive route's ``?purge_stats=true``.
+    """
+    deleted = await archive_purge_service.purge_older_than(
+        db,
+        older_than_days=body.older_than_days,
+        purge_stats=body.purge_stats,
+    )
+    return ArchivePurgeResponse(deleted=deleted, purge_stats=body.purge_stats)
 
 
 @router.get("/purge/settings", response_model=ArchivePurgeSettings)
@@ -63,7 +82,7 @@ async def get_archive_purge_settings(
     _: User | None = Depends(require_permission_if_auth_enabled(Permission.ARCHIVES_PURGE)),
 ):
     cfg = await archive_purge_service.get_settings(db)
-    return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"])
+    return ArchivePurgeSettings(enabled=cfg["enabled"], days=cfg["days"], purge_stats=cfg["purge_stats"])
 
 
 @router.put("/purge/settings", response_model=ArchivePurgeSettings)
@@ -77,5 +96,7 @@ async def update_archive_purge_settings(
             status_code=400,
             detail=f"days must be between {MIN_AUTO_PURGE_DAYS} and {MAX_AUTO_PURGE_DAYS}",
         )
-    saved = await archive_purge_service.set_settings(db, enabled=body.enabled, days=body.days)
-    return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"])
+    saved = await archive_purge_service.set_settings(
+        db, enabled=body.enabled, days=body.days, purge_stats=body.purge_stats
+    )
+    return ArchivePurgeSettings(enabled=saved["enabled"], days=saved["days"], purge_stats=saved["purge_stats"])

+ 69 - 26
backend/app/api/routes/archives.py

@@ -415,38 +415,45 @@ async def list_archives_slim(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
-    """Lightweight archive listing for stats/dashboard widgets.
-
-    Returns only the fields needed for client-side aggregation,
-    skipping duplicate detection, file paths, and extra_data.
+    """Per-event listing for stats/dashboard widgets.
+
+    Reads from print_log_entries so reprints contribute each run and
+    orphaned events (archive deleted, log row survived via ON DELETE
+    SET NULL) still aggregate consistently with Quick Stats. The sliced
+    print_time_seconds is joined from the archive when available; for
+    orphan events it is null and downstream widgets fall back to the
+    measured duration_seconds.
     """
+    from backend.app.models.print_log import PrintLogEntry
+
     _validate_user_filter_permission(current_user, created_by_id)
     filters = []
     if date_from:
         dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
-        filters.append(PrintArchive.created_at >= dt_from)
+        filters.append(PrintLogEntry.created_at >= dt_from)
     if date_to:
         dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
-        filters.append(PrintArchive.created_at <= dt_to)
-    _apply_user_filter(filters, created_by_id)
+        filters.append(PrintLogEntry.created_at <= dt_to)
+    _apply_run_user_filter(filters, created_by_id)
 
     query = (
         select(
-            PrintArchive.printer_id,
-            PrintArchive.print_name,
+            PrintLogEntry.printer_id,
+            PrintLogEntry.print_name,
             PrintArchive.print_time_seconds,
-            PrintArchive.started_at,
-            PrintArchive.completed_at,
-            PrintArchive.filament_used_grams,
-            PrintArchive.filament_type,
-            PrintArchive.filament_color,
-            PrintArchive.status,
-            PrintArchive.cost,
-            PrintArchive.quantity,
-            PrintArchive.created_at,
+            PrintLogEntry.started_at,
+            PrintLogEntry.completed_at,
+            PrintLogEntry.duration_seconds,
+            PrintLogEntry.filament_used_grams,
+            PrintLogEntry.filament_type,
+            PrintLogEntry.filament_color,
+            PrintLogEntry.status,
+            PrintLogEntry.cost,
+            PrintLogEntry.created_at,
         )
+        .outerjoin(PrintArchive, PrintArchive.id == PrintLogEntry.archive_id)
         .where(*filters)
-        .order_by(PrintArchive.created_at.desc())
+        .order_by(PrintLogEntry.created_at.desc())
         .limit(limit)
         .offset(offset)
     )
@@ -459,12 +466,19 @@ async def list_archives_slim(
             "print_name": r.print_name,
             "print_time_seconds": r.print_time_seconds,
             "actual_time_seconds": (
-                int((r.completed_at - r.started_at).total_seconds())
-                if r.started_at
-                and r.completed_at
-                and r.status == "completed"
-                and (r.completed_at - r.started_at).total_seconds() > 0
-                else None
+                # Measured elapsed time for every status (#1390): failed /
+                # cancelled prints still ran for some duration, and Quick
+                # Stats already counts that. Widgets that fall back to
+                # print_time_seconds (slicer estimate) for non-completed
+                # events would diverge from Quick Stats — so expose the
+                # measured value here unconditionally.
+                r.duration_seconds
+                if r.duration_seconds and r.duration_seconds > 0
+                else (
+                    int((r.completed_at - r.started_at).total_seconds())
+                    if r.started_at and r.completed_at and (r.completed_at - r.started_at).total_seconds() > 0
+                    else None
+                )
             ),
             "filament_used_grams": r.filament_used_grams,
             "filament_type": r.filament_type,
@@ -473,7 +487,7 @@ async def list_archives_slim(
             "started_at": r.started_at,
             "completed_at": r.completed_at,
             "cost": r.cost,
-            "quantity": r.quantity,
+            "quantity": 1,
             "created_at": r.created_at,
         }
         for r in rows
@@ -2911,6 +2925,12 @@ async def upload_archive(
 
     try:
         content = await file.read()
+        # #1401: same content validation as library upload — catches
+        # raw-gcode-renamed-to-.3mf and other unprintable shapes before
+        # archiving them and offering them up for print.
+        from backend.app.api.routes.library import validate_print_file_upload
+
+        validate_print_file_upload(file.filename, content)
         temp_path.write_bytes(content)
 
         service = ArchiveService(db)
@@ -2937,6 +2957,8 @@ async def upload_archives_bulk(
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
     """Bulk upload multiple 3MF files to archive."""
+    from backend.app.api.routes.library import validate_print_file_upload
+
     results = []
     errors = []
 
@@ -2951,6 +2973,15 @@ async def upload_archives_bulk(
 
         try:
             content = await file.read()
+            # #1401: bulk-upload variant of the library validation. Collect
+            # the rejection per-file rather than aborting the whole batch
+            # so one bad file in a 10-file drag-drop doesn't lose the
+            # other nine.
+            try:
+                validate_print_file_upload(file.filename, content)
+            except HTTPException as exc:
+                errors.append({"filename": file.filename, "error": exc.detail})
+                continue
             temp_path.write_bytes(content)
 
             service = ArchiveService(db)
@@ -3864,6 +3895,12 @@ async def upload_source_3mf(
     source_path = source_dir / source_filename
 
     content = await file.read()
+    # #1401: validate zip header on source 3MF uploads too — source files
+    # are uploaded for reprint and slicing, so an invalid one breaks the
+    # same downstream paths as a bad sliced file.
+    from backend.app.api.routes.library import validate_print_file_upload
+
+    validate_print_file_upload(file.filename, content)
     source_path.write_bytes(content)
 
     # Update archive with source path (relative to base_dir)
@@ -4065,6 +4102,12 @@ async def upload_source_3mf_by_name(
     source_path = source_dir / source_filename
 
     content = await file.read()
+    # #1401: same zip-header check as the other upload routes — the
+    # match-by-name endpoint is used by slicer post-processing scripts,
+    # so a misconfigured script is exactly how a bad 3MF would slip in.
+    from backend.app.api.routes.library import validate_print_file_upload
+
+    validate_print_file_upload(file.filename, content)
     source_path.write_bytes(content)
 
     # Update archive with source path

+ 49 - 14
backend/app/api/routes/camera.py

@@ -37,6 +37,7 @@ from backend.app.services.camera_fanout import (
     iter_subscriber,
     shutdown_broadcaster,
 )
+from backend.app.services.camera_profiles import get_camera_profile
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["camera"])
@@ -291,13 +292,6 @@ async def _read_ffmpeg_stderr(process: asyncio.subprocess.Process) -> str | None
         return None
 
 
-# Max consecutive RTSP reconnections before giving up.
-# Some printer firmwares (notably P2S) drop RTSP sessions after a few seconds,
-# so we transparently respawn ffmpeg to keep the MJPEG stream alive.
-_RTSP_MAX_RECONNECTS = 30
-_RTSP_RECONNECT_DELAY = 0.2  # seconds between respawns
-
-
 async def generate_rtsp_mjpeg_stream(
     ip_address: str,
     access_code: str,
@@ -311,6 +305,9 @@ async def generate_rtsp_mjpeg_stream(
 
     This is for X1/H2/P2 models that support RTSP streaming.
     Auto-reconnects when the printer drops the RTSP session (common on P2S).
+    Per-model knobs (probesize, analyzeduration, reconnect cadence) come from
+    :func:`camera_profiles.get_camera_profile` so quirky firmwares can be
+    handled by adding a profile entry rather than tuning a global constant.
     """
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
@@ -318,6 +315,8 @@ async def generate_rtsp_mjpeg_stream(
         yield (b"--frame\r\nContent-Type: text/plain\r\n\r\nError: ffmpeg not installed\r\n")
         return
 
+    profile = get_camera_profile(model)
+
     port = get_camera_port(model)
 
     # Use a local TLS proxy so Python's OpenSSL handles TLS instead of
@@ -341,13 +340,14 @@ async def generate_rtsp_mjpeg_stream(
         "-max_delay",
         "500000",  # 0.5 seconds max delay
         "-probesize",
-        "32",  # Minimal probing for faster startup
+        str(profile.probesize),
         "-analyzeduration",
-        "0",  # Skip format analysis for faster startup
+        str(profile.analyzeduration),
         "-fflags",
         "nobuffer",  # Reduce internal buffering
         "-flags",
         "low_delay",  # Minimize decode latency
+        *profile.extra_ffmpeg_input_args,
         "-i",
         camera_url,
         "-f",
@@ -382,7 +382,7 @@ async def generate_rtsp_mjpeg_stream(
     got_any_frames = False
 
     try:
-        while reconnect_count <= _RTSP_MAX_RECONNECTS:
+        while reconnect_count <= profile.rtsp_reconnect_max:
             # Check for client disconnect before (re)connecting
             if disconnect_event and disconnect_event.is_set():
                 break
@@ -391,11 +391,11 @@ async def generate_rtsp_mjpeg_stream(
                 logger.info(
                     "RTSP reconnecting (%d/%d) for %s (stream_id=%s)",
                     reconnect_count,
-                    _RTSP_MAX_RECONNECTS,
+                    profile.rtsp_reconnect_max,
                     ip_address,
                     stream_id,
                 )
-                await asyncio.sleep(_RTSP_RECONNECT_DELAY)
+                await asyncio.sleep(profile.rtsp_reconnect_delay)
                 if disconnect_event and disconnect_event.is_set():
                     break
 
@@ -523,10 +523,10 @@ async def generate_rtsp_mjpeg_stream(
             # Normal exit (shouldn't reach here, but be safe)
             break
 
-        if reconnect_count > _RTSP_MAX_RECONNECTS:
+        if reconnect_count > profile.rtsp_reconnect_max:
             logger.error(
                 "RTSP max reconnects (%d) reached for %s (stream_id=%s)",
-                _RTSP_MAX_RECONNECTS,
+                profile.rtsp_reconnect_max,
                 ip_address,
                 stream_id,
             )
@@ -927,6 +927,41 @@ async def test_camera(
     return result
 
 
+@router.post("/{printer_id}/camera/diagnose")
+async def diagnose_camera_route(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
+):
+    """Run staged diagnostics for a printer's camera path.
+
+    Returns a structured result the frontend renders inline so users can
+    self-diagnose "connection lost" before opening a ticket. See
+    ``camera_diagnose`` for stage details and the live-stream shortcut.
+    """
+    import time
+
+    from backend.app.services.camera_diagnose import diagnose_camera
+
+    printer = await get_printer_or_404(printer_id, db)
+
+    # Look up live-stream evidence so the diagnostic can short-circuit
+    # instead of fighting a viewer for the printer's single camera slot.
+    has_live = is_stream_active(printer_id)
+    last_ts = _last_frame_times.get(printer_id) if has_live else None
+    live_age = (time.time() - last_ts) if (has_live and last_ts) else None
+
+    result = await diagnose_camera(
+        ip_address=printer.ip_address,
+        access_code=printer.access_code,
+        model=printer.model,
+        printer_id=printer_id,
+        has_live_stream=has_live,
+        live_frame_age_seconds=live_age,
+    )
+    return result.to_dict()
+
+
 @router.get("/{printer_id}/camera/status")
 async def camera_status(
     printer_id: int,

+ 57 - 0
backend/app/api/routes/github_backup.py

@@ -28,6 +28,39 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/github-backup", tags=["github-backup"])
 
 
+_PUBLIC_REPO_ERROR = (
+    "Refusing to save: the target repository is not private. Bambuddy backups "
+    "include MQTT credentials, Home Assistant tokens, Prometheus tokens, your "
+    "Bambu Cloud email, the printer access codes via K-profiles, and other "
+    "settings that must not be exposed publicly. Make the repository private "
+    "in your provider's UI and try again."
+)
+_UNKNOWN_VISIBILITY_ERROR = (
+    "Refusing to save: could not confirm the target repository is private. "
+    "Bambuddy backups contain credentials and must never go to a public or "
+    "internal-visibility repository. Verify the URL, the access token's scope, "
+    "and that your provider exposes the 'private' / 'visibility' field on its "
+    "repo API."
+)
+
+
+async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> None:
+    """Run a test_connection and refuse if the repo is not confirmed private.
+
+    Used by POST and PATCH /config so a backup configuration can never be
+    saved against a public repository.
+    """
+    result = await github_backup_service.test_connection(repo_url, token, provider=provider)
+    if not result.get("success"):
+        message = result.get("message") or "Connection test failed"
+        raise HTTPException(status_code=400, detail=f"Cannot verify repository: {message}")
+    is_private = result.get("is_private")
+    if is_private is None:
+        raise HTTPException(status_code=400, detail=_UNKNOWN_VISIBILITY_ERROR)
+    if is_private is False:
+        raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
+
+
 def _config_to_response(config: GitHubBackupConfig) -> dict:
     """Convert config model to response dict."""
     return {
@@ -79,7 +112,16 @@ async def save_config(
     """Create or update GitHub backup configuration.
 
     Only one configuration is supported. If one exists, it will be updated.
+    The target repository must be private — Bambuddy backups carry MQTT
+    credentials, HA/Prometheus tokens, the Bambu Cloud email, and printer
+    access codes (via K-profiles), so a public repo is a hard reject.
     """
+    await _enforce_private_repo(
+        config_data.repository_url,
+        config_data.access_token,
+        config_data.provider.value,
+    )
+
     # Check for existing config
     result = await db.execute(select(GitHubBackupConfig).limit(1))
     config = result.scalar_one_or_none()
@@ -163,6 +205,21 @@ async def update_config(
                 detail="This URL uses HTTP instead of HTTPS. Enable 'Allow insecure HTTP' if your instance does not use TLS.",
             )
 
+    # Re-verify the repo is private whenever the target changes — new URL,
+    # new token, or new provider. We DON'T re-test on every unrelated PATCH
+    # (e.g. toggling backup_archives) so flipping schedule settings doesn't
+    # round-trip a live API call.
+    target_changed = "repository_url" in update_dict or "access_token" in update_dict or "provider" in update_dict
+    if target_changed:
+        provider_value = update_dict.get("provider", config.provider)
+        if hasattr(provider_value, "value"):
+            provider_value = provider_value.value
+        await _enforce_private_repo(
+            update_dict.get("repository_url", config.repository_url),
+            update_dict.get("access_token", config.access_token),
+            provider_value,
+        )
+
     for key, value in update_dict.items():
         if key in ("schedule_type", "provider") and value is not None:
             setattr(config, key, value.value)

+ 54 - 0
backend/app/api/routes/inventory.py

@@ -1065,6 +1065,60 @@ async def restore_spool(
     return result.scalar_one()
 
 
+@router.post("/spools/{spool_id}/reset-usage", response_model=SpoolResponse)
+async def reset_spool_usage(
+    spool_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Zero the displayed "Total Consumed" counter without touching remaining.
+
+    Stamps `weight_used_baseline = weight_used` so the Inventory page's
+    `weight_used - baseline` display reads 0, while `label_weight -
+    weight_used` (remaining) is unchanged. weight_locked is also left
+    alone — the spool keeps receiving AMS auto-sync updates. Matches
+    Spoolman's split between used_weight and remaining_weight (#1390).
+    """
+    result = await db.execute(select(Spool).where(Spool.id == spool_id))
+    spool = result.scalar_one_or_none()
+    if not spool:
+        raise HTTPException(404, "Spool not found")
+
+    spool.weight_used_baseline = spool.weight_used or 0
+    await db.commit()
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool_id))
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return result.scalar_one()
+
+
+@router.post("/spools/reset-usage-bulk")
+async def bulk_reset_spool_usage(
+    payload: dict,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Bulk-stamp baseline = weight_used across the given spool IDs.
+
+    Caller passes an explicit list of IDs — no "reset all" shortcut, since
+    a typo on a wildcard would wipe the entire inventory's tracking.
+    Same semantics as the per-spool endpoint: remaining is preserved,
+    weight_locked is left alone.
+    """
+    spool_ids = payload.get("spool_ids")
+    if not isinstance(spool_ids, list) or not spool_ids:
+        raise HTTPException(400, "spool_ids must be a non-empty list")
+    if not all(isinstance(sid, int) for sid in spool_ids):
+        raise HTTPException(400, "spool_ids must contain integers")
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(spool_ids)))
+    spools = list(result.scalars().all())
+    for spool in spools:
+        spool.weight_used_baseline = spool.weight_used or 0
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"reset": len(spools)}
+
+
 # ── K-Profiles ───────────────────────────────────────────────────────────────
 
 

+ 12 - 9
backend/app/api/routes/kprofiles.py

@@ -113,14 +113,17 @@ async def set_kprofile(
     if not client or not client.state.connected:
         raise HTTPException(400, "Printer not connected")
 
-    # Detect dual-nozzle families by serial number prefix.
-    # H2 series: legacy "094"; post-2026 H2C batches ship with "31B8B" (#1105).
-    # X2D series: "20P9".
-    is_h2d = printer.serial_number.startswith(("094", "20P9", "31B8B"))
-
-    if is_edit and is_h2d:
-        # H2D in-place edit: use cali_idx with slot_id=0 and empty setting_id
-        logger.info("[API] H2D in-place edit: cali_idx=%s", profile.slot_id)
+    # Detect dual-nozzle for the in-place edit format. Runtime detection from
+    # device.extruder.info beats serial-prefix heuristics — H2S shares prefix
+    # "094" with H2D but is single-nozzle (#1386). Model name is the fallback
+    # for the brief window after connect before push data arrives.
+    is_dual_nozzle = client._is_dual_nozzle or (
+        printer.model and printer.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
+    )
+
+    if is_edit and is_dual_nozzle:
+        # Dual-nozzle in-place edit: use cali_idx with slot_id=0 and empty setting_id
+        logger.info("[API] Dual-nozzle in-place edit: cali_idx=%s", profile.slot_id)
         success = client.set_kprofile(
             filament_id=profile.filament_id,
             name=profile.name,
@@ -133,7 +136,7 @@ async def set_kprofile(
             cali_idx=profile.slot_id,  # Pass the original slot for in-place edit
         )
     elif is_edit:
-        # Non-H2D edit: use delete + add approach
+        # Single-nozzle edit: use delete + add approach
         logger.info("[API] Edit: deleting existing profile slot_id=%s", profile.slot_id)
         delete_success = client.delete_kprofile(
             cali_idx=profile.slot_id,

+ 10 - 2
backend/app/api/routes/labels.py

@@ -37,7 +37,8 @@ logger = logging.getLogger(__name__)
 router = APIRouter(tags=["labels"])
 
 _VALID_TEMPLATES: tuple[TemplateName, ...] = (
-    "ams_30x15",
+    "ams_holder_74x33",
+    "ams_holder_75x55",
     "box_40x30",
     "box_62x29",
     "avery_5160",
@@ -51,7 +52,14 @@ MAX_LABELS_PER_REQUEST = 500
 
 class LabelRequest(BaseModel):
     spool_ids: list[int] = Field(..., min_length=1, max_length=MAX_LABELS_PER_REQUEST)
-    template: Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
+    template: Literal[
+        "ams_holder_74x33",
+        "ams_holder_75x55",
+        "box_40x30",
+        "box_62x29",
+        "avery_5160",
+        "avery_l7160",
+    ]
 
 
 def _split_extra_colors(raw: str | None) -> list[str] | None:

+ 59 - 2
backend/app/api/routes/library.py

@@ -139,6 +139,55 @@ def calculate_file_hash(file_path: Path) -> str:
     return sha256_hash.hexdigest()
 
 
+def validate_print_file_upload(filename: str, content: bytes) -> None:
+    """Reject obviously-unprintable uploads early so the printer doesn't see them (#1401).
+
+    Bambu printers in network mode only parse ``.gcode.3mf`` zip containers
+    — raw ``.gcode`` and corrupt/non-zip ``.3mf`` uploads cascade into a
+    confusing "Printing stopped because the printer was unable to parse the
+    3mf file" rejection 30 seconds after the user clicks Print. The
+    background dispatcher (``background_dispatch.py``) appends ``.3mf`` to
+    a raw-gcode filename when constructing the FTP destination, which is
+    how the printer ends up with a file named ``.gcode.3mf`` whose body is
+    raw gcode — exactly the shape that triggers the firmware parse
+    failure. Catching both classes here gives an actionable error at the
+    upload itself.
+
+    Compares the filename suffix rather than ``os.path.splitext`` because
+    compound extensions like ``.gcode.3mf`` show up as just ``.3mf`` after
+    ``splitext`` — same content validation needs to fire for both
+    single-``.3mf`` and ``.gcode.3mf`` uploads.
+
+    Raises ``HTTPException(400, ...)`` with a human-readable message on
+    rejection; returns ``None`` for valid (or irrelevant — e.g. STL,
+    image) uploads.
+    """
+    lower_filename = filename.lower()
+    is_3mf_upload = lower_filename.endswith(".3mf")
+    is_raw_gcode_upload = lower_filename.endswith(".gcode") and not lower_filename.endswith(".gcode.3mf")
+
+    if is_raw_gcode_upload:
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "Raw .gcode files can't be printed on Bambu printers in network mode — "
+                "they need a .gcode.3mf zip container (gcode plus metadata). Re-export from "
+                "your slicer and make sure the file ends in '.gcode.3mf', not just '.gcode'. "
+                "If your OS hides extensions, double-check the file with the extension visible."
+            ),
+        )
+
+    if is_3mf_upload and not content.startswith(b"PK\x03\x04"):
+        raise HTTPException(
+            status_code=400,
+            detail=(
+                "This .3mf file isn't a valid ZIP container. 3MF files are ZIP archives — "
+                "either the file is corrupted or it's raw gcode renamed to .3mf. Re-export "
+                "from your slicer using its 'Export Plate Sliced File' action."
+            ),
+        )
+
+
 def _resolve_upload_destination(target_folder: LibraryFolder | None, filename: str) -> tuple[Path, bool]:
     """Resolve the on-disk destination for an uploaded file.
 
@@ -1508,11 +1557,19 @@ async def upload_file(
 
         # Writable external folders write through to the mount so the file is
         # visible outside Bambuddy (#1112); everything else lands under the
-        # internal library dir with a UUID-scoped filename.
+        # internal library dir with a UUID-scoped filename. Resolved BEFORE
+        # the content validation below so folder-permission rejections
+        # (403 read-only, 400 missing path, 409 collision) still surface
+        # before any "bad file format" 400 — preserves existing error
+        # ordering / tests.
         file_path, is_external_upload = _resolve_upload_destination(target_folder, filename)
 
-        # Save file
+        # Read upload now so the validation can sniff magic bytes; the file
+        # is written to disk only after the checks. #1401.
         content = await file.read()
+        validate_print_file_upload(filename, content)
+
+        # Save file
         with open(file_path, "wb") as f:
             f.write(content)
 

+ 48 - 5
backend/app/api/routes/printers.py

@@ -67,12 +67,39 @@ async def create_printer(
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CREATE),
     db: AsyncSession = Depends(get_db),
 ):
-    """Add a new printer."""
+    """Add a new printer.
+
+    Verifies the MQTT connection succeeds before persisting. A wrong access
+    code or unreachable IP would otherwise create a printer row that shows
+    as an empty / never-connecting card on the dashboard — those reports
+    were turning into support tickets that all traced back to a mistyped
+    access code.
+    """
     # Check if serial number already exists
     result = await db.execute(select(Printer).where(Printer.serial_number == printer_data.serial_number))
     if result.scalar_one_or_none():
         raise HTTPException(400, "Printer with this serial number already exists")
 
+    test_result = await printer_manager.test_connection(
+        ip_address=printer_data.ip_address,
+        serial_number=printer_data.serial_number,
+        access_code=printer_data.access_code,
+    )
+    if not test_result.get("success"):
+        # The frontend renders the user-facing message via i18n on `code`;
+        # `message` is an English fallback for non-UI clients (curl / scripts).
+        raise HTTPException(
+            status_code=400,
+            detail={
+                "code": "printer_connection_failed",
+                "message": (
+                    "Could not connect to the printer. Verify IP address, serial number, "
+                    "and access code, and confirm LAN-only mode is enabled. "
+                    "The printer was not added."
+                ),
+            },
+        )
+
     printer = Printer(**printer_data.model_dump())
     db.add(printer)
     await db.commit()
@@ -748,10 +775,17 @@ async def test_printer_connection(
 # different plates always fetch a fresh thumbnail without needing plate in the key.
 _cover_cache: dict[int, dict[tuple[str, str], bytes]] = {}
 
+# Negative cache (#1420): when a cover lookup exhausts every FTP path with 550
+# (file sliced on SD card, not on printer storage), remember the failure so the
+# next request short-circuits to 404 instead of re-hammering FTP 8 paths deep.
+# Cleared on print start alongside _cover_cache.
+_cover_404_cache: dict[int, set[tuple[str, str]]] = {}
+
 
 def clear_cover_cache(printer_id: int) -> None:
     """Clear cached cover images for a printer. Call on print start to avoid stale thumbnails."""
     _cover_cache.pop(printer_id, None)
+    _cover_404_cache.pop(printer_id, None)
 
 
 @router.get("/{printer_id}/cover")
@@ -801,10 +835,15 @@ async def get_printer_cover(
     # runs on every print start, so a re-dispatch with a different plate gets
     # a fresh image regardless. Pre-#1166 the key included plate_num, but with
     # late plate resolution the cache check would always miss.
-    if printer_id in _cover_cache:
-        cache_key = (subtask_name, view_key)
-        if cache_key in _cover_cache[printer_id]:
-            return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
+    cache_key = (subtask_name, view_key)
+    if printer_id in _cover_cache and cache_key in _cover_cache[printer_id]:
+        return Response(content=_cover_cache[printer_id][cache_key], media_type="image/png")
+
+    # Negative-cache short-circuit (#1420): if a prior lookup for this same
+    # subtask + view already failed, don't replay 8 FTP retries on every page
+    # refresh. _cover_404_cache is cleared on print start.
+    if printer_id in _cover_404_cache and cache_key in _cover_404_cache[printer_id]:
+        raise HTTPException(404, f"No cover available for '{subtask_name}' (cached)")
 
     # Build possible 3MF filenames from subtask_name
     # Bambu printers may store files as "name.gcode.3mf" (sliced via Bambu Studio)
@@ -890,6 +929,9 @@ async def get_printer_cover(
             raise HTTPException(503, f"FTP download temporarily unavailable: {last_error}")
 
         if not downloaded:
+            # Remember this failure so subsequent requests for the same print
+            # skip the 8-path FTP fan-out (#1420).
+            _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             raise HTTPException(
                 404,
                 f"Could not download 3MF file for '{subtask_name}' from printer {printer.ip_address}. Tried: {possible_filenames}",
@@ -979,6 +1021,7 @@ async def get_printer_cover(
                     _cover_cache[printer_id][(subtask_name, view_key)] = image_data
                     return Response(content=image_data, media_type="image/png")
 
+            _cover_404_cache.setdefault(printer_id, set()).add(cache_key)
             raise HTTPException(404, "No thumbnail found in 3MF file")
         finally:
             zf.close()

+ 141 - 17
backend/app/api/routes/spoolman_inventory.py

@@ -440,18 +440,24 @@ async def create_spool(
 
     spool, price_warnings = await _apply_price_if_set(client, spool, data.cost_per_kg)
 
-    # Persist slicer_filament under the spool's extra dict (mirror update_spool).
-    if data.slicer_filament is not None or data.slicer_filament_name is not None:
+    # Persist slicer_filament AND color_name under the spool's extra dict
+    # (mirror update_spool). Spoolman has no `color_name` field on filament
+    # (#1357) so we own the round-trip ourselves.
+    if data.slicer_filament is not None or data.slicer_filament_name is not None or data.color_name is not None:
         # Ensure extra fields are registered before write.
         if data.slicer_filament is not None:
             await client.ensure_extra_field("bambu_slicer_filament")
         if data.slicer_filament_name is not None:
             await client.ensure_extra_field("bambu_slicer_filament_name")
+        if data.color_name is not None:
+            await client.ensure_extra_field("bambu_color_name")
         new_extra: dict = {}
         if data.slicer_filament is not None:
             new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament)
         if data.slicer_filament_name is not None:
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name)
+        if data.color_name is not None:
+            new_extra["bambu_color_name"] = json.dumps(data.color_name)
         if new_extra:
             try:
                 async with _translate_spoolman_errors():
@@ -459,7 +465,7 @@ async def create_spool(
             except HTTPException:
                 # Best-effort — the spool already exists, log and continue.
                 logger.warning(
-                    "Failed to persist slicer_filament for spool %s",
+                    "Failed to persist slicer_filament/color_name for spool %s",
                     spool.get("id"),
                 )
 
@@ -574,21 +580,83 @@ async def update_spool(
     cur_color = (cur_filament.get("color_hex") or "808080").upper().removeprefix("#")
     rgba = data.rgba if data.rgba is not None else (cur_color + "FF")
     label_weight = data.label_weight if data.label_weight is not None else int(cur_filament.get("weight") or 1000)
-    weight_used = data.weight_used if data.weight_used is not None else float(current.get("used_weight") or 0)
+    # Default weight_used from the synthetic mapping (label - remaining) so an
+    # edit that doesn't touch the weight field preserves Spoolman's real
+    # remaining_weight after a "Reset usage to 0" — the previous code read
+    # Spoolman's used_weight directly, which is 0 post-reset, so
+    # `remaining = label - 0 = 1000` would overwrite the real remaining
+    # the next time the user edited any other field (#1390).
+    cur_remaining_raw = current.get("remaining_weight")
+    if cur_remaining_raw is not None:
+        synthetic_used = max(0.0, float(label_weight) - float(cur_remaining_raw))
+    else:
+        synthetic_used = float(current.get("used_weight") or 0)
+    weight_used = data.weight_used if data.weight_used is not None else synthetic_used
     note = data.note if data.note is not None else current.get("comment")
     storage_location_changed = "storage_location" in data.model_fields_set
     storage_location = data.storage_location if storage_location_changed else None
 
     color_hex = rgba[:6]
-    async with _translate_spoolman_errors():
-        filament_id = await client.find_or_create_filament(
-            material=material,
-            subtype=subtype or "",
-            brand=brand,
-            color_hex=color_hex,
-            label_weight=label_weight,
-            color_name=color_name,
-        )
+
+    # Resolve which filament this spool should be linked to AFTER the edit.
+    #
+    # The old behaviour was always `find_or_create_filament`, which proliferated
+    # duplicate Spoolman filaments whenever the user changed any field that
+    # made up the match key (material/subtype/brand/color) — every edit minted
+    # a fresh row and orphaned the previous one (#1357 follow-up). To match
+    # internal-mode behaviour ([[feedback_inventory_modes_parity]]: editing a
+    # spool does not proliferate new entities), prefer PATCHing the current
+    # filament in place when it's a singleton.
+    cur_filament_id = cur_filament.get("id")
+    desired_name = f"{material} {subtype}".strip() if subtype else material
+    cur_color_norm = (cur_filament.get("color_hex") or "").upper()[:6]
+    cur_vendor_name = (cur_vendor.get("name") or "").strip()
+    cur_weight_int = int(cur_filament.get("weight") or 0)
+    metadata_unchanged = (
+        cur_filament_id
+        and (cur_filament.get("name") or "").strip() == desired_name
+        and (cur_filament.get("material") or "").upper() == material.upper()
+        and cur_color_norm == color_hex.upper()
+        and cur_vendor_name.lower() == ((brand or "").strip().lower())
+        and cur_weight_int == int(label_weight)
+    )
+
+    if metadata_unchanged:
+        # No filament-side change at all — re-use the existing link, skip
+        # find_or_create entirely so a no-op edit (e.g. just changing
+        # weight_used or note) never even touches the filament catalogue.
+        filament_id = cur_filament_id
+    else:
+        async with _translate_spoolman_errors():
+            shared = await client.is_filament_shared(cur_filament_id, spool_id) if cur_filament_id else False
+        if cur_filament_id and not shared:
+            # Singleton filament — PATCH it in place so the user's edit lands
+            # on the row their spool already points at instead of orphaning it.
+            patch_body: dict = {
+                "name": desired_name,
+                "material": material,
+                "color_hex": color_hex,
+                "weight": float(label_weight),
+            }
+            if brand:
+                vendor_id = await client.find_or_create_vendor(brand)
+                patch_body["vendor_id"] = vendor_id
+            async with _translate_spoolman_errors():
+                await client.patch_filament(cur_filament_id, patch_body)
+            filament_id = cur_filament_id
+        else:
+            # Filament is shared with other spools — PATCHing it in place would
+            # silently rewrite their metadata too. Fall back to find-or-create
+            # so only this spool's link moves.
+            async with _translate_spoolman_errors():
+                filament_id = await client.find_or_create_filament(
+                    material=material,
+                    subtype=subtype or "",
+                    brand=brand,
+                    color_hex=color_hex,
+                    label_weight=label_weight,
+                    color_name=color_name,
+                )
     if not filament_id:
         raise HTTPException(status_code=500, detail="Failed to find or create filament in Spoolman")
 
@@ -633,25 +701,33 @@ async def update_spool(
                 clear_location=storage_location_changed and not storage_location,
             )
 
-    # Persist BambuStudio slicer preset under the spool's extra dict.
-    # Spoolman doesn't have a native field for this, so we round-trip via
-    # extra and unpack in _map_spoolman_spool. Only writes when the request
+    # Persist BambuStudio slicer preset AND color_name under spool.extra.
+    # Spoolman has no native fields for these — color_name was confirmed
+    # absent from the FilamentUpdateParameters schema in 0.23.1 (#1357), so
+    # writing `filament.color_name` was a silent no-op that left every
+    # edit looking "not saved". They all round-trip via extra and get
+    # unpacked in _map_spoolman_spool. Only writes when the request
     # explicitly set the field — passing null/omitting leaves the existing
     # extra entry untouched (write empty string to clear).
     sf_set = "slicer_filament" in data.model_fields_set
     sfn_set = "slicer_filament_name" in data.model_fields_set
-    if sf_set or sfn_set:
+    cn_set = "color_name" in data.model_fields_set
+    if sf_set or sfn_set or cn_set:
         # Ensure extra fields are registered (Spoolman rejects PATCHes with
         # unknown keys with HTTP 400). Idempotent if startup already ran this.
         if sf_set:
             await client.ensure_extra_field("bambu_slicer_filament")
         if sfn_set:
             await client.ensure_extra_field("bambu_slicer_filament_name")
+        if cn_set:
+            await client.ensure_extra_field("bambu_color_name")
         new_extra: dict = {}
         if sf_set:
             new_extra["bambu_slicer_filament"] = json.dumps(data.slicer_filament or "")
         if sfn_set:
             new_extra["bambu_slicer_filament_name"] = json.dumps(data.slicer_filament_name or "")
+        if cn_set:
+            new_extra["bambu_color_name"] = json.dumps(data.color_name or "")
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
 
@@ -705,6 +781,54 @@ async def restore_spool(
         raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
 
 
+@router.post("/spools/{spool_id}/reset-usage")
+async def reset_spool_usage(
+    spool_id: int = Path(..., gt=0),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Zero the spool's used_weight in Spoolman without touching anything else."""
+    client = await _get_client(db)
+    async with _translate_spoolman_errors():
+        spool = await client.reset_spool_usage(spool_id)
+    try:
+        return _map_spoolman_spool(spool)
+    except ValueError as exc:
+        logger.warning("Malformed Spoolman spool (id=%r): %s", spool_id, exc)
+        raise HTTPException(status_code=502, detail="Spoolman returned malformed spool data") from exc
+
+
+@router.post("/spools/reset-usage-bulk")
+async def bulk_reset_spool_usage(
+    payload: dict = Body(...),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Bulk-reset used_weight to 0 across the given Spoolman spool IDs.
+
+    Caller passes an explicit list of IDs — no "reset all" shortcut, since
+    a typo on a wildcard would wipe the entire inventory's tracking.
+    Returns the count of spools successfully reset; individual failures are
+    logged but do not abort the batch.
+    """
+    spool_ids = payload.get("spool_ids")
+    if not isinstance(spool_ids, list) or not spool_ids:
+        raise HTTPException(status_code=400, detail="spool_ids must be a non-empty list")
+    if not all(isinstance(sid, int) for sid in spool_ids):
+        raise HTTPException(status_code=400, detail="spool_ids must contain integers")
+
+    client = await _get_client(db)
+    reset_count = 0
+    for spool_id in spool_ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.reset_spool_usage(spool_id)
+            reset_count += 1
+        except HTTPException as exc:
+            logger.warning("Spoolman reset-usage failed for spool %s: %s", spool_id, exc.detail)
+    return {"reset": reset_count}
+
+
 @router.patch("/spools/{spool_id}/weight")
 async def sync_spool_weight(
     *,

+ 96 - 0
backend/app/api/routes/updates.py

@@ -6,6 +6,7 @@ import os
 import re
 import shutil
 import sys
+import time
 
 import httpx
 from fastapi import APIRouter, BackgroundTasks, Depends
@@ -31,6 +32,61 @@ _update_status = {
     "error": None,
 }
 
+# GitHub rate-limit backoff (#1420): when api.github.com returns 403 with
+# X-RateLimit-Remaining=0, refuse to retry until X-RateLimit-Reset (epoch
+# seconds). Falls back to a 1-hour pause if the header is absent. Prevents
+# the update checker from hammering GitHub once the unauthenticated quota
+# (60 req/hr per source IP) is exhausted.
+_GITHUB_RATE_LIMIT_FALLBACK_SECONDS = 3600
+_github_rate_limit_until: float = 0.0
+
+
+def _seconds_until_github_unblocked() -> float:
+    """Return seconds remaining until GitHub backoff lifts, or 0 if unblocked."""
+    remaining = _github_rate_limit_until - time.time()
+    return remaining if remaining > 0 else 0.0
+
+
+def _record_github_rate_limit(response: httpx.Response) -> None:
+    """Set the backoff window from a GitHub 403 response's headers."""
+    global _github_rate_limit_until
+    reset_header = response.headers.get("X-RateLimit-Reset")
+    reset_at: float | None = None
+    if reset_header:
+        try:
+            reset_at = float(reset_header)
+        except ValueError:
+            reset_at = None
+    if reset_at is None:
+        reset_at = time.time() + _GITHUB_RATE_LIMIT_FALLBACK_SECONDS
+    # Floor at a 60s minimum: protects against clock skew between the container
+    # and GitHub (parsed reset epoch in the past would otherwise leave us with
+    # no real backoff and we'd hammer GitHub again immediately).
+    reset_at = max(reset_at, time.time() + 60)
+    # Only extend the window — never shorten it via an out-of-order response.
+    if reset_at > _github_rate_limit_until:
+        _github_rate_limit_until = reset_at
+    logger.warning(
+        "GitHub rate limit hit; suppressing update checks for %.0fs (reset header=%s)",
+        _seconds_until_github_unblocked(),
+        reset_header,
+    )
+
+
+def _is_github_rate_limit_response(response: httpx.Response) -> bool:
+    """Detect a rate-limit response from GitHub (403/429 with Remaining=0)."""
+    if response.status_code not in (403, 429):
+        return False
+    remaining = response.headers.get("X-RateLimit-Remaining")
+    if remaining == "0":
+        return True
+    # Some proxies strip the header; fall back to body inspection.
+    try:
+        body = response.text or ""
+    except Exception:
+        body = ""
+    return "rate limit" in body.lower() or "API rate limit exceeded" in body
+
 
 def _is_docker_environment() -> bool:
     """Detect if running inside a Docker container."""
@@ -287,6 +343,23 @@ async def check_for_updates(
     beta_setting = result.scalar_one_or_none()
     include_beta = beta_setting and beta_setting.value.lower() == "true"
 
+    # Short-circuit if we're still inside a GitHub rate-limit backoff window (#1420).
+    backoff_remaining = _seconds_until_github_unblocked()
+    if backoff_remaining > 0:
+        _update_status = {
+            "status": "error",
+            "progress": 0,
+            "message": "GitHub rate limit reached",
+            "error": "GitHub rate limit reached; retry later",
+        }
+        return {
+            "update_available": False,
+            "current_version": APP_VERSION,
+            "latest_version": None,
+            "error": "GitHub rate limit reached; retry later",
+            "retry_after_seconds": int(backoff_remaining),
+        }
+
     _update_status = {
         "status": "checking",
         "progress": 0,
@@ -302,6 +375,22 @@ async def check_for_updates(
                 timeout=10.0,
             )
 
+            if _is_github_rate_limit_response(response):
+                _record_github_rate_limit(response)
+                _update_status = {
+                    "status": "error",
+                    "progress": 0,
+                    "message": "GitHub rate limit reached",
+                    "error": "GitHub rate limit reached; retry later",
+                }
+                return {
+                    "update_available": False,
+                    "current_version": APP_VERSION,
+                    "latest_version": None,
+                    "error": "GitHub rate limit reached; retry later",
+                    "retry_after_seconds": int(_seconds_until_github_unblocked()),
+                }
+
             if response.status_code == 404:
                 # No releases yet
                 _update_status = {
@@ -419,6 +508,10 @@ async def _discover_target_release(db: AsyncSession) -> str | None:
     beta_setting = result.scalar_one_or_none()
     include_beta = beta_setting and beta_setting.value.lower() == "true"
 
+    if _seconds_until_github_unblocked() > 0:
+        logger.warning("Skipping update target discovery: GitHub rate-limit backoff still active")
+        return None
+
     try:
         async with httpx.AsyncClient() as client:
             response = await client.get(
@@ -426,6 +519,9 @@ async def _discover_target_release(db: AsyncSession) -> str | None:
                 headers={"Accept": "application/vnd.github.v3+json"},
                 timeout=10.0,
             )
+            if _is_github_rate_limit_response(response):
+                _record_github_rate_limit(response)
+                return None
             response.raise_for_status()
             releases = response.json()
     except (httpx.HTTPError, ValueError) as exc:

+ 17 - 19
backend/app/api/routes/webhook.py

@@ -136,7 +136,16 @@ async def webhook_start_print(
     api_key: APIKey = Depends(get_api_key),
     db: AsyncSession = Depends(get_db),
 ):
-    """Start the next queued print on a printer.
+    """Trigger the next manual-start queue item on a printer.
+
+    Mirrors `POST /print-queue/{item_id}/start`: clears `manual_start` on
+    the next pending item so the scheduler picks it up — which handles
+    FTP upload, AMS mapping, and all print options (timelapse,
+    bed_levelling, etc.) correctly via the queue's stored fields. The
+    previous implementation called `printer_manager.start_print()`
+    directly with `archive_id` as the filename arg and no print options,
+    bypassing the upload step entirely and discarding the user's
+    workflow choices — it 500'd before ever reaching the printer.
 
     Requires 'can_control_printer' permission.
     """
@@ -163,25 +172,14 @@ async def webhook_start_print(
     if not queue_item:
         raise HTTPException(status_code=404, detail="No pending prints in queue")
 
-    # Check if printer is ready
-    status = printer_manager.get_status(printer_id)
-    if not status or not status.get("connected"):
-        raise HTTPException(status_code=503, detail="Printer not connected")
-
-    if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
-        raise HTTPException(status_code=409, detail=f"Printer is busy (state: {status.get('state')})")
-
-    # Start the print with plate_id if available
-    try:
-        await printer_manager.start_print(
-            printer_id,
-            queue_item.archive_id,
-            plate_id=queue_item.plate_id or 1,
-        )
-    except Exception as e:
-        logger.error("Failed to start print: %s", e)
-        raise HTTPException(status_code=500, detail=str(e))
+    # Clear manual_start so the scheduler will dispatch. If the item was
+    # already auto-dispatchable this is a no-op; the scheduler will still
+    # pick it up on its next tick.
+    queue_item.manual_start = False
+    await db.commit()
+    await db.refresh(queue_item)
 
+    logger.info("Webhook started queue item %s on printer %s", queue_item.id, printer_id)
     return {"message": "Print started", "queue_item_id": queue_item.id}
 
 

+ 1 - 1
backend/app/core/config.py

@@ -6,7 +6,7 @@ from pathlib import Path
 from pydantic_settings import BaseSettings
 
 # Application version - single source of truth
-APP_VERSION = "0.2.4.1"
+APP_VERSION = "0.2.4.2"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

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

@@ -1547,6 +1547,11 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
     # Migration: Add user-editable storage location to spool table
     await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
+    # Migration: Add weight_used_baseline anchor for the resettable "Total
+    # Consumed" stat (#1390). Existing spools default to 0 (no baseline),
+    # so the counter starts unaffected; pressing "Reset usage to 0" now
+    # stamps baseline = weight_used without touching remaining.
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0")
     # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
     # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
     # ALTER COLUMN ... TYPE is PostgreSQL-only syntax; SQLite ignores VARCHAR sizes so no-op there.
@@ -2519,6 +2524,97 @@ async def run_migrations(conn):
         conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_archive_id ON print_log_entries (archive_id)"
     )
 
+    # Backfill PrintLogEntry → PrintArchive linkage and per-event cost/energy
+    # for pre-#1378 rows the column-add migration left NULL (#1390).
+    #
+    # Without this backfill the user's Quick Stats show Filament Cost = 0 and
+    # Time Accuracy empty even though their archives carry both, because:
+    #
+    #   - the new stats queries SUM PrintLogEntry.cost (NULL for old rows)
+    #   - the time-accuracy query JOINs PrintArchive ON archive_id (NULL for
+    #     old rows, so old runs get excluded from the average)
+    #
+    # Pre-#1378, archive.cost / energy_kwh / energy_cost were overwritten by
+    # each rerun, so the current archive values represent the *latest* run.
+    # Backfilling them onto the latest matching PrintLogEntry per archive
+    # reconstructs the pre-fix total exactly (sum across archives stays
+    # unchanged), and leaves earlier reprints with NULL cost so they
+    # contribute zero — matching the "first/latest writes, rest stay NULL"
+    # convention #1378 introduced for new prints.
+    #
+    # DML, not DDL — use conn.execute() inside a savepoint per _safe_execute's
+    # own docstring. SQL is plain ANSI (correlated UPDATE, MAX/GROUP BY/HAVING,
+    # CASE in HAVING) and runs unchanged on SQLite + PostgreSQL; verified
+    # against postgres:16-alpine + asyncpg.
+    #
+    # Step 1: link old log entries to their archive via print_name + printer_id.
+    # Picks the highest-id matching archive when multiple share the same key
+    # (newest archive wins — closest to the log's overwrite-then-leave shape).
+    from sqlalchemy import text as _text
+
+    async with conn.begin_nested():
+        await conn.execute(
+            _text("""
+            UPDATE print_log_entries
+            SET archive_id = (
+                SELECT a.id
+                FROM print_archives a
+                WHERE a.print_name = print_log_entries.print_name
+                  AND (
+                      a.printer_id = print_log_entries.printer_id
+                      OR (a.printer_id IS NULL AND print_log_entries.printer_id IS NULL)
+                  )
+                ORDER BY a.id DESC
+                LIMIT 1
+            )
+            WHERE archive_id IS NULL AND print_name IS NOT NULL
+            """)
+        )
+
+    # Step 2: backfill cost / energy_kwh / energy_cost onto the latest linked
+    # log entry per archive — the row whose creation time best matches the
+    # value currently stored on the archive (overwrite-on-reprint semantics
+    # under the old design). Only fires for archives where NO log entry has
+    # cost set yet, which gives the migration a clean idempotency property:
+    # the second pass sees the archive already has a cost-bearing run and
+    # leaves the rest of its history NULL (instead of marching up the
+    # ID-ordered list of NULL runs on every pass).
+    async with conn.begin_nested():
+        await conn.execute(
+            _text("""
+            UPDATE print_log_entries
+            SET cost = (SELECT cost FROM print_archives WHERE id = print_log_entries.archive_id),
+                energy_kwh = (SELECT energy_kwh FROM print_archives WHERE id = print_log_entries.archive_id),
+                energy_cost = (SELECT energy_cost FROM print_archives WHERE id = print_log_entries.archive_id)
+            WHERE id IN (
+                SELECT MAX(id)
+                FROM print_log_entries
+                WHERE archive_id IS NOT NULL
+                GROUP BY archive_id
+                HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0
+            )
+            """)
+        )
+
+    # Migration: smart_plugs gets per-plug auto-off-after-drying toggle and
+    # delay (#1349). Fires whenever any AMS attached to the linked printer
+    # finishes a dry cycle. Plain ANSI ALTER TABLE works on both SQLite and
+    # Postgres for INTEGER/BOOLEAN with simple defaults.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_after_drying BOOLEAN DEFAULT 0")
+        await _safe_execute(
+            conn, "ALTER TABLE smart_plugs ADD COLUMN off_delay_after_drying_minutes INTEGER DEFAULT 10"
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS auto_off_after_drying BOOLEAN DEFAULT false",
+        )
+        await _safe_execute(
+            conn,
+            "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
+        )
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 47 - 11
backend/app/main.py

@@ -593,22 +593,24 @@ def _compute_run_filament_grams(
     progress: float | int | None,
     usage_results: list[dict] | None,
 ) -> float | None:
-    """Per-run filament for PrintLogEntry, partial-aware (#1378).
-
-    For ``completed``: returns the archive's slicer estimate (which approximates
-    actual since the print finished). For failed / cancelled / stopped:
-        1. Sum of tracked spool deltas in ``usage_results`` (most accurate
-           when inventory is configured for the print).
-        2. ``estimate * progress%`` (when no inventory delta available).
-        3. ``None`` (no signal at all — e.g. progress=0 and no spool data).
+    """Per-run filament for PrintLogEntry, partial- and tracker-aware (#1378, #1390).
+
+    Priority for every status:
+        1. Sum of tracked spool deltas in ``usage_results`` (AMS-measured
+           weight delta — same source that drives "Total Consumed" on the
+           Inventory page, so Stats and Inventory totals stay aligned).
+        2. For ``completed``: the slicer estimate (no tracker available, fall
+           back to the canonical "this print used X" value).
+        3. For partial statuses: ``estimate * progress%``.
+        4. ``None`` if nothing is known.
     """
-    if status == "completed":
-        return archive_filament_used_grams
-
     tracked_grams = sum(r.get("weight_used") or 0 for r in (usage_results or []))
     if tracked_grams > 0:
         return round(tracked_grams, 1)
 
+    if status == "completed":
+        return archive_filament_used_grams
+
     if archive_filament_used_grams:
         scale = max(0.0, min(((progress or 0) / 100.0), 1.0))
         if scale > 0:
@@ -2039,6 +2041,15 @@ async def on_print_start(printer_id: int, data: dict):
                 archive.started_at = datetime.now(timezone.utc)
                 if subtask_id and not archive.subtask_id:
                     archive.subtask_id = subtask_id
+                # #1403 follow-up: VP-queue archives are created with
+                # printer_id=None at queue-add time (we don't know which
+                # printer will run the job yet). When the print actually
+                # starts on a specific printer the expected-archive lookup
+                # used to skip this assignment, leaving printer_id=None
+                # forever — which then disables the "Scan for timelapse"
+                # button in ArchivesPage (gated on !archive.printer_id).
+                if archive.printer_id != printer_id:
+                    archive.printer_id = printer_id
                 await db.commit()
 
                 # Track as active print
@@ -4732,6 +4743,31 @@ async def lifespan(app: FastAPI):
 
     printer_manager.set_bed_temp_update_callback(on_bed_temp_update)
 
+    async def on_drying_complete(printer_id: int, ams_id: int):
+        """Smart-plug auto-off-after-drying trigger (#1349).
+
+        Fires once per AMS unit when ``dry_time`` falls from >0 to 0. The
+        manager walks all plugs linked to this printer and turns off only
+        the ones with ``auto_off_after_drying`` enabled, after their
+        per-plug delay. Multiple AMS units finishing close together (e.g. a
+        dual-AMS dry that ends within the same MQTT push) call this once
+        per unit — the manager's ``_cancel_pending_off`` collapses
+        repeated scheduling on the same plug to one timer, so duplicate
+        fires are safe.
+        """
+        try:
+            async with async_session() as db:
+                await smart_plug_manager.on_drying_complete(printer_id, db)
+        except Exception as e:
+            logging.getLogger(__name__).warning(
+                "Failed to schedule auto-off-after-drying for printer %d (AMS %d): %s",
+                printer_id,
+                ams_id,
+                e,
+            )
+
+    printer_manager.set_drying_complete_callback(on_drying_complete)
+
     # Initialize MQTT relay from settings
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting

+ 9 - 0
backend/app/models/smart_plug.py

@@ -86,6 +86,15 @@ class SmartPlug(Base):
     off_delay_minutes: Mapped[int] = mapped_column(Integer, default=5)  # For time mode
     off_temp_threshold: Mapped[int] = mapped_column(Integer, default=70)  # For temp mode (°C)
 
+    # Auto-off after AMS drying completes (#1349). Independent of `auto_off`
+    # (which only fires after a print finishes). Uses its own delay because
+    # the AMS is hot after a drying cycle and users may want longer cooldown
+    # than the print-finish default. Fires whenever any AMS attached to the
+    # linked printer finishes a dry cycle — Bambuddy doesn't model per-AMS
+    # plug routing, the trigger is plug-vs-printer-level.
+    auto_off_after_drying: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    off_delay_after_drying_minutes: Mapped[int] = mapped_column(Integer, default=10, server_default="10")
+
     # Optional auth (some Tasmota configs require it)
     username: Mapped[str | None] = mapped_column(String(50), nullable=True)
     password: Mapped[str | None] = mapped_column(String(100), nullable=True)

+ 6 - 0
backend/app/models/spool.py

@@ -31,6 +31,12 @@ class Spool(Base):
         Integer
     )  # Reference to spool_catalog entry for core weight
     weight_used: Mapped[float] = mapped_column(Float, default=0)  # Consumed grams
+    # Anchor for the resettable "Total Consumed" stat. The displayed counter
+    # is `weight_used - weight_used_baseline`; the Inventory page's "Reset
+    # usage to 0" action stamps baseline = weight_used so the counter zeroes
+    # without disturbing remaining (= label_weight - weight_used). Matches
+    # Spoolman's split between used_weight and remaining_weight (#1390).
+    weight_used_baseline: Mapped[float] = mapped_column(Float, default=0)
     weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)  # Lock weight from AMS auto-sync
     last_scale_weight: Mapped[int | None] = mapped_column(Integer)  # Last gross weight from scale (g)
     last_weighed_at: Mapped[datetime | None] = mapped_column(DateTime)  # When last weighed

+ 9 - 0
backend/app/schemas/archive_purge.py

@@ -14,12 +14,21 @@ class ArchivePurgePreviewResponse(BaseModel):
 
 class ArchivePurgeRequest(BaseModel):
     older_than_days: int = Field(ge=1, le=3650)
+    # #1390: parity with single-archive delete. False (default) soft-deletes
+    # — files off disk, archive row hidden, Quick Stats preserved. True
+    # also drops PrintLogEntry rows so the contribution leaves /stats.
+    purge_stats: bool = False
 
 
 class ArchivePurgeResponse(BaseModel):
     deleted: int
+    purge_stats: bool = False
 
 
 class ArchivePurgeSettings(BaseModel):
     enabled: bool = False
     days: int = Field(default=365, ge=7, le=3650)
+    # #1390: scheduled-purge equivalent of the single-delete checkbox.
+    # Default False — preserves Quick Stats; flip to True to also drop
+    # the contribution from /stats every time the sweeper runs.
+    purge_stats: bool = False

+ 5 - 0
backend/app/schemas/github_backup.py

@@ -176,6 +176,11 @@ class GitHubTestConnectionResponse(BaseModel):
     message: str
     repo_name: str | None = None
     permissions: dict | None = None
+    # True = confirmed private. False = confirmed public (or non-private such
+    # as GitLab "internal"). None = could not be determined (older self-hosted
+    # API, non-2xx response). The backup config endpoints refuse anything that
+    # isn't an explicit True.
+    is_private: bool | None = None
 
 
 class GitHubBackupTriggerResponse(BaseModel):

+ 8 - 0
backend/app/schemas/smart_plug.py

@@ -69,6 +69,11 @@ class SmartPlugBase(BaseModel):
     off_delay_mode: Literal["time", "temperature"] = "time"
     off_delay_minutes: int = Field(default=5, ge=0, le=60)
     off_temp_threshold: int = Field(default=70, ge=30, le=150)
+    # #1349: auto-off after AMS drying completes. Independent of `auto_off`
+    # (print-finish). Fires whenever any AMS on the linked printer finishes
+    # a dry cycle.
+    auto_off_after_drying: bool = False
+    off_delay_after_drying_minutes: int = Field(default=10, ge=0, le=120)
     # Power alerts
     power_alert_enabled: bool = False
     power_alert_high: float | None = Field(default=None, ge=0, le=5000)  # Alert when power > this (watts)
@@ -156,6 +161,9 @@ class SmartPlugUpdate(BaseModel):
     off_delay_mode: Literal["time", "temperature"] | None = None
     off_delay_minutes: int | None = Field(default=None, ge=0, le=60)
     off_temp_threshold: int | None = Field(default=None, ge=30, le=150)
+    # #1349: per-plug drying auto-off.
+    auto_off_after_drying: bool | None = None
+    off_delay_after_drying_minutes: int | None = Field(default=None, ge=0, le=120)
     username: str | None = None
     password: str | None = None
     # Power alerts

+ 5 - 0
backend/app/schemas/spool.py

@@ -100,6 +100,11 @@ class SpoolBase(BaseModel):
     core_weight: int = 250
     core_weight_catalog_id: int | None = None
     weight_used: float = 0
+    # Anchor for the resettable "Total Consumed" display. The Inventory
+    # page shows `weight_used - weight_used_baseline`; the per-spool /
+    # bulk "Reset usage to 0" action sets baseline = weight_used so the
+    # counter zeroes without touching remaining (#1390).
+    weight_used_baseline: float = 0
     slicer_filament: str | None = None
     slicer_filament_name: str | None = None
     nozzle_temp_min: int | None = None

+ 96 - 24
backend/app/services/archive_purge.py

@@ -29,6 +29,14 @@ logger = logging.getLogger(__name__)
 AUTO_PURGE_ENABLED_KEY = "archive_auto_purge_enabled"
 AUTO_PURGE_DAYS_KEY = "archive_auto_purge_days"
 AUTO_PURGE_LAST_RUN_KEY = "archive_auto_purge_last_run"
+# #1390 follow-up: bulk and scheduled purge inherit the same "soft vs hard"
+# choice the single-archive delete already exposes (#1343). When False
+# (default), each purged archive goes through soft_delete_archive — files
+# removed from disk, row hidden via `deleted_at`, PrintLogEntry rows
+# untouched so Quick Stats keeps every contribution. When True, the linked
+# log rows are deleted up front and the archive row is hard-removed,
+# matching the route's `?purge_stats=true` semantics.
+AUTO_PURGE_STATS_KEY = "archive_auto_purge_stats"
 
 DEFAULT_AUTO_PURGE_DAYS = 365
 # 7-day floor mirrors the library auto-purge; anything shorter treats archives
@@ -104,9 +112,11 @@ class ArchivePurgeService:
             row.value = value
 
     async def get_settings(self, db: AsyncSession) -> dict:
-        """Return ``{enabled, days}``. Missing keys default to disabled / 365d."""
+        """Return ``{enabled, days, purge_stats}``. Missing keys default to
+        disabled / 365d / soft-delete (Quick Stats preserved)."""
         enabled_raw = await self._read_setting(db, AUTO_PURGE_ENABLED_KEY)
         days_raw = await self._read_setting(db, AUTO_PURGE_DAYS_KEY)
+        stats_raw = await self._read_setting(db, AUTO_PURGE_STATS_KEY)
 
         enabled = (enabled_raw or "false").lower() == "true"
         try:
@@ -114,14 +124,16 @@ class ArchivePurgeService:
         except (TypeError, ValueError):
             days = DEFAULT_AUTO_PURGE_DAYS
         days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, days))
-        return {"enabled": enabled, "days": days}
+        purge_stats = (stats_raw or "false").lower() == "true"
+        return {"enabled": enabled, "days": days, "purge_stats": purge_stats}
 
-    async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int) -> dict:
+    async def set_settings(self, db: AsyncSession, *, enabled: bool, days: int, purge_stats: bool = False) -> dict:
         clamped_days = max(MIN_AUTO_PURGE_DAYS, min(MAX_AUTO_PURGE_DAYS, int(days)))
         await self._write_setting(db, AUTO_PURGE_ENABLED_KEY, "true" if enabled else "false")
         await self._write_setting(db, AUTO_PURGE_DAYS_KEY, str(clamped_days))
+        await self._write_setting(db, AUTO_PURGE_STATS_KEY, "true" if purge_stats else "false")
         await db.commit()
-        return {"enabled": enabled, "days": clamped_days}
+        return {"enabled": enabled, "days": clamped_days, "purge_stats": purge_stats}
 
     async def _get_last_run(self, db: AsyncSession) -> datetime | None:
         raw = await self._read_setting(db, AUTO_PURGE_LAST_RUN_KEY)
@@ -147,13 +159,19 @@ class ArchivePurgeService:
         if last is not None and (now - last) < timedelta(hours=24):
             return 0
 
-        deleted = await self.purge_older_than(db, older_than_days=cfg["days"])
+        deleted = await self.purge_older_than(
+            db,
+            older_than_days=cfg["days"],
+            purge_stats=cfg["purge_stats"],
+        )
         await self._stamp_last_run(db, now)
         if deleted:
             logger.info(
-                "Archive auto-purge: hard-deleted %d archive(s) (threshold=%d days)",
+                "Archive auto-purge: %s %d archive(s) (threshold=%d days, purge_stats=%s)",
+                "hard-deleted" if cfg["purge_stats"] else "soft-deleted",
                 deleted,
                 cfg["days"],
+                cfg["purge_stats"],
             )
         return deleted
 
@@ -164,8 +182,16 @@ class ArchivePurgeService:
         db: AsyncSession,
         older_than_days: int,
         sample_limit: int = 5,
+        *,
+        purge_stats: bool = False,
     ) -> dict:
-        """Count + size of archives eligible for purge. Read-only."""
+        """Count + size of archives eligible for purge. Read-only.
+
+        Soft-delete mode (default) excludes already-soft-deleted rows so the
+        admin slider's "eligible" count matches what a fresh purge would
+        actually touch. Hard-delete mode counts every row past the cutoff —
+        already-soft-deleted rows are eligible for promotion to hard-delete.
+        """
         if older_than_days < 1:
             return {
                 "count": 0,
@@ -178,15 +204,21 @@ class ArchivePurgeService:
         last_activity = _last_activity_expr()
         clause = last_activity < cutoff
 
-        count_result = await db.execute(select(func.count(PrintArchive.id)).where(clause))
+        count_stmt = select(func.count(PrintArchive.id)).where(clause)
+        size_stmt = select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause)
+        sample_stmt = select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
+        if not purge_stats:
+            count_stmt = count_stmt.where(PrintArchive.deleted_at.is_(None))
+            size_stmt = size_stmt.where(PrintArchive.deleted_at.is_(None))
+            sample_stmt = sample_stmt.where(PrintArchive.deleted_at.is_(None))
+
+        count_result = await db.execute(count_stmt)
         count = int(count_result.scalar() or 0)
 
-        size_result = await db.execute(select(func.coalesce(func.sum(PrintArchive.file_size), 0)).where(clause))
+        size_result = await db.execute(size_stmt)
         total_bytes = int(size_result.scalar() or 0)
 
-        sample_result = await db.execute(
-            select(PrintArchive.filename).where(clause).order_by(last_activity).limit(sample_limit)
-        )
+        sample_result = await db.execute(sample_stmt)
         samples = [row[0] for row in sample_result.all()]
 
         return {
@@ -196,21 +228,44 @@ class ArchivePurgeService:
             "older_than_days": older_than_days,
         }
 
-    async def purge_older_than(self, db: AsyncSession, older_than_days: int) -> int:
-        """Hard-delete archives older than ``older_than_days``. Returns count.
-
-        Delegates to :meth:`ArchiveService.delete_archive` for every row so the
-        on-disk cleanup (3MF, thumbnail, timelapse, photos) goes through the
-        same safety-checked path as manual deletion. Each delete runs in its
-        own session so a commit-per-row doesn't churn the caller's session
-        (and matches how the sweeper uses :func:`_database.async_session` in production).
+    async def purge_older_than(
+        self,
+        db: AsyncSession,
+        older_than_days: int,
+        *,
+        purge_stats: bool = False,
+    ) -> int:
+        """Bulk-delete archives older than ``older_than_days``. Returns count.
+
+        Two modes, parameter-controlled (#1390):
+
+        * ``purge_stats=False`` (default): each archive goes through
+          :meth:`ArchiveService.soft_delete_archive` — files removed from disk
+          and the row hidden via ``deleted_at``, but the linked
+          ``PrintLogEntry`` rows are untouched so Quick Stats keeps every
+          contribution (filament, cost, energy, time accuracy).
+        * ``purge_stats=True``: linked log rows are hard-deleted up front and
+          the archive row is hard-removed via
+          :meth:`ArchiveService.delete_archive`. Matches the single-archive
+          ``DELETE /archives/{id}?purge_stats=true`` semantics from #1343.
+
+        Each delete runs in its own session so a commit-per-row doesn't churn
+        the caller's session (matches how the sweeper uses
+        :func:`_database.async_session` in production).
         """
         if older_than_days < 1:
             return 0
         now = datetime.now(timezone.utc)
         cutoff = _age_cutoff(now, older_than_days)
 
-        id_result = await db.execute(select(PrintArchive.id).where(_last_activity_expr() < cutoff))
+        # Soft-delete mode must also skip rows already soft-deleted, otherwise
+        # a repeat sweeper run keeps re-touching the same rows. Hard-delete
+        # mode doesn't filter — already-soft-deleted rows are eligible for
+        # promotion to hard-delete when the user opts in.
+        select_stmt = select(PrintArchive.id).where(_last_activity_expr() < cutoff)
+        if not purge_stats:
+            select_stmt = select_stmt.where(PrintArchive.deleted_at.is_(None))
+        id_result = await db.execute(select_stmt)
         ids = [row[0] for row in id_result.all()]
         if not ids:
             return 0
@@ -219,13 +274,30 @@ class ArchivePurgeService:
         for archive_id in ids:
             async with _database.async_session() as delete_db:
                 service = ArchiveService(delete_db)
-                if await service.delete_archive(archive_id):
-                    deleted += 1
+                if purge_stats:
+                    # Hard-delete linked PrintLogEntry rows first so their
+                    # filament / cost contributions stop counting in /stats.
+                    # FK is ON DELETE SET NULL, so without this they'd
+                    # survive the archive row and keep showing up in totals
+                    # (#1343 / #1378 / #1390).
+                    from sqlalchemy import delete as sa_delete
+
+                    from backend.app.models.print_log import PrintLogEntry
+
+                    await delete_db.execute(sa_delete(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id))
+                    await delete_db.commit()
+                    if await service.delete_archive(archive_id):
+                        deleted += 1
+                else:
+                    if await service.soft_delete_archive(archive_id):
+                        deleted += 1
         if deleted:
             logger.info(
-                "Archive purge: hard-deleted %d archive(s) (older_than_days=%d)",
+                "Archive purge: %s %d archive(s) (older_than_days=%d, purge_stats=%s)",
+                "hard-deleted" if purge_stats else "soft-deleted",
                 deleted,
                 older_than_days,
+                purge_stats,
             )
         return deleted
 

+ 2 - 2
backend/app/services/background_dispatch.py

@@ -681,7 +681,7 @@ class BackgroundDispatchService:
                     timelapse=job.options.get("timelapse", False),
                     bed_levelling=job.options.get("bed_levelling", True),
                     flow_cali=job.options.get("flow_cali", False),
-                    vibration_cali=job.options.get("vibration_cali", False),
+                    vibration_cali=job.options.get("vibration_cali", True),
                     layer_inspect=job.options.get("layer_inspect", False),
                     use_ams=job.options.get("use_ams", True),
                 )
@@ -886,7 +886,7 @@ class BackgroundDispatchService:
                     timelapse=job.options.get("timelapse", False),
                     bed_levelling=job.options.get("bed_levelling", True),
                     flow_cali=job.options.get("flow_cali", False),
-                    vibration_cali=job.options.get("vibration_cali", False),
+                    vibration_cali=job.options.get("vibration_cali", True),
                     layer_inspect=job.options.get("layer_inspect", False),
                     use_ams=job.options.get("use_ams", True),
                 )

+ 72 - 4
backend/app/services/bambu_ftp.py

@@ -447,9 +447,46 @@ class BambuFTPClient:
                     logger.info("FTP STOR confirmed for %s: %s", remote_path, resp.strip())
                 finally:
                     self._ftp.sock.settimeout(old_timeout)
+            except ftplib.Error as e:
+                # Some P2S firmware revisions return ftplib.Error (e.g. 426
+                # "Failure reading network stream") on voidresp() even when
+                # the file landed fully on the SD card — the TLS data
+                # channel close races the 226 confirmation (#1417 follow-up).
+                # Verify via SIZE: if the server-side file size matches what
+                # we just uploaded, the file is intact and we proceed with
+                # a warning. If not — or SIZE itself fails — the transfer
+                # was genuinely truncated and we must fail so the print
+                # command doesn't go out for a partial 3MF (the original
+                # reason this catch was tightened in the previous round).
+                try:
+                    server_size = self._ftp.size(remote_path)
+                except (OSError, ftplib.Error) as size_err:
+                    logger.debug("Post-error SIZE check failed: %s", size_err)
+                    server_size = None
+                if server_size is not None and server_size == file_size:
+                    logger.warning(
+                        "FTP STOR returned %s for %s but file is intact on the "
+                        "printer (%s bytes match) — proceeding: %s",
+                        type(e).__name__,
+                        remote_path,
+                        file_size,
+                        e,
+                    )
+                else:
+                    logger.error(
+                        "FTP STOR rejected by printer for %s: %s (%s); server size=%s expected=%s",
+                        remote_path,
+                        e,
+                        type(e).__name__,
+                        server_size,
+                        file_size,
+                    )
+                    raise
             except Exception as e:
-                # Timeout or error reading 226 — log but proceed, the data
-                # was fully sent so the file is likely on the SD card.
+                # Timeout or socket-level error reading 226 — the data was sent
+                # on our side and the printer may still have written the file.
+                # H2D can take 30+ seconds to send 226 after the data channel
+                # closes, so we proceed with a warning rather than failing here.
                 logger.warning(
                     "FTP STOR confirmation not received for %s (proceeding): %s (%s)",
                     remote_path,
@@ -527,7 +564,10 @@ class BambuFTPClient:
                     conn.close()
                 except OSError:
                     pass
-            # Wait for 226 confirmation (see upload_file for rationale)
+            # Wait for 226 confirmation (see upload_file for rationale).
+            # ftplib.Error subclasses (e.g. 426 error_temp) mean the server
+            # rejected the transfer and the file is partial — fail. Other
+            # exceptions (timeout, socket-level) are tolerated as in upload_file.
             try:
                 old_timeout = self._ftp.sock.gettimeout()
                 self._ftp.sock.settimeout(max(self.timeout, 60))
@@ -535,8 +575,36 @@ class BambuFTPClient:
                     self._ftp.voidresp()
                 finally:
                     self._ftp.sock.settimeout(old_timeout)
+            except ftplib.Error as e:
+                # Same SIZE-verify path as upload_file (#1417 follow-up):
+                # tolerate a transient 426 if the bytes are actually on the
+                # printer, fail loudly if they aren't.
+                try:
+                    server_size = self._ftp.size(remote_path)
+                except (OSError, ftplib.Error) as size_err:
+                    logger.debug("Post-error SIZE check failed: %s", size_err)
+                    server_size = None
+                if server_size is not None and server_size == len(data):
+                    logger.warning(
+                        "FTP STOR returned %s for %s but file is intact on the "
+                        "printer (%s bytes match) — proceeding: %s",
+                        type(e).__name__,
+                        remote_path,
+                        len(data),
+                        e,
+                    )
+                else:
+                    logger.error(
+                        "FTP STOR rejected by printer for %s: %s (%s); server size=%s expected=%s",
+                        remote_path,
+                        e,
+                        type(e).__name__,
+                        server_size,
+                        len(data),
+                    )
+                    return False
             except Exception:
-                pass  # Best-effort — data was sent, proceed
+                pass  # Timeout / socket-level — proceed, data was sent.
             return True
         except (OSError, ftplib.Error):
             return False

+ 112 - 34
backend/app/services/bambu_mqtt.py

@@ -332,6 +332,7 @@ class BambuMQTTClient:
         on_ams_change: Callable[[list], None] | None = None,
         on_layer_change: Callable[[int], None] | None = None,
         on_bed_temp_update: Callable[[float], None] | None = None,
+        on_drying_complete: Callable[[int], None] | None = None,
     ):
         self.ip_address = ip_address
         self.serial_number = serial_number
@@ -343,6 +344,13 @@ class BambuMQTTClient:
         self.on_ams_change = on_ams_change
         self.on_layer_change = on_layer_change
         self.on_bed_temp_update = on_bed_temp_update
+        # #1349: fired when an AMS unit's dry_time falls from >0 to 0 — i.e.
+        # the drying cycle just finished (auto- or manually-triggered).
+        # Receives the AMS id of the unit that finished drying.
+        self.on_drying_complete = on_drying_complete
+        # Per-AMS previous dry_time, used to detect the falling edge above.
+        # Seeded lazily as we observe each AMS unit.
+        self._previous_dry_times: dict[int, int] = {}
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -1748,20 +1756,36 @@ class BambuMQTTClient:
                         tray_id = int(tray_id_raw) if isinstance(tray_id_raw, str) else tray_id_raw
                         global_bit = ams_id * 4 + tray_id
                         slot_exists = (tray_exist_bits >> global_bit) & 1
-                        if not slot_exists and tray.get("tray_type"):
-                            # Slot is marked empty but has data - clear it
-                            logger.debug(
-                                f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
-                                f"(tray_exist_bits bit {global_bit} = 0)"
-                            )
-                            tray["tray_type"] = ""
-                            tray["tray_sub_brands"] = ""
-                            tray["tray_color"] = ""
-                            tray["tray_id_name"] = ""
-                            tray["tag_uid"] = "0000000000000000"
-                            tray["tray_uuid"] = "00000000000000000000000000000000"
-                            tray["tray_info_idx"] = ""
-                            tray["remain"] = 0
+                        if not slot_exists:
+                            # #1322 follow-up (by @RosdasHH): the bitmask is
+                            # BambuStudio's canonical "no spool" signal, and
+                            # works across every firmware variant (P1S, A1
+                            # Mini, post-restart, post-Reset-Slot, steady-
+                            # state). Promote to state=9 (firmware's
+                            # explicit "no spool" code) so downstream
+                            # readers — printers.py's API serializer,
+                            # inventory.py's `tray_state in {9, 10}`
+                            # short-circuit, the AMS card — see one
+                            # canonical signal instead of guessing from
+                            # payload shape. Int (not "9") to match the
+                            # downstream `==` comparison.
+                            tray["state"] = 9
+                            if tray.get("tray_type"):
+                                # Stale data from before the slot went empty
+                                # — clear it so the AMS view doesn't render a
+                                # colour/material that's no longer there.
+                                logger.debug(
+                                    f"[{self.serial_number}] Clearing empty slot: AMS {ams_id} slot {tray_id} "
+                                    f"(tray_exist_bits bit {global_bit} = 0)"
+                                )
+                                tray["tray_type"] = ""
+                                tray["tray_sub_brands"] = ""
+                                tray["tray_color"] = ""
+                                tray["tray_id_name"] = ""
+                                tray["tag_uid"] = "0000000000000000"
+                                tray["tray_uuid"] = "00000000000000000000000000000000"
+                                tray["tray_info_idx"] = ""
+                                tray["remain"] = 0
 
         self.state.raw_data["ams"] = merged_ams
 
@@ -1829,6 +1853,34 @@ class BambuMQTTClient:
         # Persist updated drying fields back to raw_data
         self.state.raw_data["ams"] = merged_ams
 
+        # Detect AMS drying-complete falling edge per-unit (#1349). When an
+        # AMS's `dry_time` transitions from >0 to 0 the cycle just finished
+        # — fire the callback so smart-plug auto-off-after-drying can run.
+        # Works identically for queue-triggered, ambient, and manual drying
+        # because we observe the firmware-reported state, not our own intent.
+        if self.on_drying_complete:
+            for ams_unit in merged_ams:
+                try:
+                    ams_id = int(ams_unit.get("id", -1))
+                except (TypeError, ValueError):
+                    continue
+                if ams_id < 0:
+                    continue
+                try:
+                    current = int(ams_unit.get("dry_time") or 0)
+                except (TypeError, ValueError):
+                    current = 0
+                previous = self._previous_dry_times.get(ams_id, 0)
+                self._previous_dry_times[ams_id] = current
+                if previous > 0 and current == 0:
+                    logger.info(
+                        "[%s] AMS %d drying complete (dry_time %d → 0)",
+                        self.serial_number,
+                        ams_id,
+                        previous,
+                    )
+                    self.on_drying_complete(ams_id)
+
         # Create a hash of relevant AMS data to detect changes
         ams_hash_data = []
         for ams_unit in ams_list:
@@ -3161,11 +3213,31 @@ class BambuMQTTClient:
         """
         if self._client and self.state.connected:
             # Bambu print command format - matches Bambu Studio's format
-            # H2D series requires integer values (0/1) for calibration/leveling fields
-            # but use_ams MUST remain boolean — H2D Pro firmware interprets integer
-            # values as nozzle index (1 = deputy nozzle), causing wrong extruder routing
-            # Other printers (X1C, P1S, A1, etc.) require actual booleans for all fields
-            is_h2d = self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "H2S", "X2D")
+            # H2-family firmware (H2D, H2D Pro, H2C, H2S, X2D) requires integer
+            # values (0/1) for calibration/leveling fields. X1C/P1S/A1/P2S need
+            # actual booleans. use_ams stays boolean across the board — H2D Pro
+            # firmware interprets integer use_ams as nozzle index (1 = deputy),
+            # causing wrong extruder routing (#1386 root cause was here too: the
+            # old flag conflated firmware-format with dual-nozzle routing).
+            is_h_family = self.model and self.model.upper().strip() in (
+                "H2D",
+                "H2D PRO",
+                "H2DPRO",
+                "H2C",
+                "H2S",
+                "X2D",
+            )
+            # Dual-nozzle routing for external spool (254 = deputy/left,
+            # 255 = main/right) and the use_ams=False fallback. H2S is in the
+            # H2 firmware family but is single-nozzle, despite sharing serial
+            # prefix "094" with H2D. Prefer runtime detection from
+            # device.extruder.info (set in _handle_push_status); fall back to
+            # model name for the brief window after connect before push data
+            # arrives. _is_dual_nozzle only ever flips False→True, so it's safe
+            # as the primary signal.
+            is_dual_nozzle = self._is_dual_nozzle or (
+                self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
+            )
 
             # Build ams_mapping2 from ams_mapping (detailed format with ams_id/slot_id)
             ams_mapping2 = []
@@ -3194,7 +3266,7 @@ class BambuMQTTClient:
                         # to 07FF_8012 "Failed to get AMS mapping table" or stuck prints.
                         # Only H2D dual-nozzle printers use 254 (deputy/left nozzle).
                         flat_ams_mapping.append(-1)
-                        ext_ams_id = tray_id if is_h2d else 255
+                        ext_ams_id = tray_id if is_dual_nozzle else 255
                         ams_mapping2.append({"ams_id": ext_ams_id, "slot_id": 0})
                     elif tray_id >= 128:
                         # AMS-HT: global tray ID IS the ams_id (single tray per unit)
@@ -3209,8 +3281,11 @@ class BambuMQTTClient:
 
             # If all mapped slots are external spool (no real AMS trays), force use_ams=False.
             # P1S/P1P with no AMS rejects use_ams=True with "Failed to get AMS mapping table".
-            # Skip for H2D series — use_ams controls nozzle routing on those printers.
-            if ams_mapping and use_ams and not is_h2d:
+            # Skip for dual-nozzle printers — use_ams controls nozzle routing there.
+            # H2S falls through this gate now (#1386): it is single-nozzle and was
+            # hitting the dual-nozzle bypass, which caused 07FF_8012 when printing
+            # without an AMS attached.
+            if ams_mapping and use_ams and not is_dual_nozzle:
                 if all(t is None or int(t) < 0 or int(t) >= 254 for t in ams_mapping):
                     use_ams = False
                     logger.info(
@@ -3247,12 +3322,12 @@ class BambuMQTTClient:
                     "file": filename,
                     "md5": "",
                     "bed_type": "auto",
-                    "timelapse": (1 if timelapse else 0) if is_h2d else timelapse,
-                    "bed_leveling": (1 if bed_levelling else 0) if is_h2d else bed_levelling,
+                    "timelapse": (1 if timelapse else 0) if is_h_family else timelapse,
+                    "bed_leveling": (1 if bed_levelling else 0) if is_h_family else bed_levelling,
                     "auto_bed_leveling": 1 if bed_levelling else 0,
-                    "flow_cali": (1 if flow_cali else 0) if is_h2d else flow_cali,
-                    "vibration_cali": (1 if vibration_cali else 0) if is_h2d else vibration_cali,
-                    "layer_inspect": (1 if layer_inspect else 0) if is_h2d else layer_inspect,
+                    "flow_cali": (1 if flow_cali else 0) if is_h_family else flow_cali,
+                    "vibration_cali": (1 if vibration_cali else 0) if is_h_family else vibration_cali,
+                    "layer_inspect": (1 if layer_inspect else 0) if is_h_family else layer_inspect,
                     "use_ams": use_ams,
                     "cfg": "0",
                     "extrude_cali_flag": 0,
@@ -3266,9 +3341,9 @@ class BambuMQTTClient:
                 }
             }
 
-            if is_h2d:
+            if is_h_family:
                 logger.debug(
-                    "[%s] H2D series detected: using integer format for calibration fields (use_ams stays boolean)",
+                    "[%s] H-family firmware detected: using integer format for calibration fields (use_ams stays boolean)",
                     self.serial_number,
                 )
 
@@ -3980,11 +4055,14 @@ class BambuMQTTClient:
 
         self._sequence_id += 1
 
-        # Detect printer type by serial number prefix
-        # Dual-nozzle families:
-        #   H2 series: legacy "094"; post-2026 H2C batches ship with "31B8B" (#1105)
-        #   X2D series: "20P9"
-        is_dual_nozzle = self.serial_number.startswith(("094", "20P9", "31B8B"))
+        # Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
+        # single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
+        # Prefer runtime detection from device.extruder.info; fall back to
+        # model name. H2S is single-nozzle but shares serial prefix "094" with
+        # H2D, so a prefix-only check misclassified it (#1386).
+        is_dual_nozzle = self._is_dual_nozzle or (
+            self.model and self.model.upper().strip() in ("H2D", "H2D PRO", "H2DPRO", "H2C", "X2D")
+        )
 
         if is_dual_nozzle:
             # H2D format: uses extruder_id, nozzle_id, nozzle_diameter

+ 288 - 0
backend/app/services/camera_diagnose.py

@@ -0,0 +1,288 @@
+"""End-to-end camera diagnostic, surfaced via ``POST /printers/{id}/camera/diagnose``.
+
+Cuts off the "camera broken" support-ticket loop at the user's screen by
+running the printer-side camera path through staged checks (TCP, end-
+to-end frame capture) and reporting WHICH stage failed plus a
+remediation key the frontend can render translated.
+
+The goal isn't to be a perfect protocol analyser — it's to be the diff
+between "user opens a ticket with 'connection lost'" and "user sees
+'Printer not reachable; check IP and LAN-only mode'" before they ever
+write a message.
+
+Stages
+------
+
+1. **tcp_reachable** — open a TCP socket to the camera port (322 for
+   RTSPS models, 6000 for the chamber-image-protocol A1 / P1 family).
+   Distinguishes "printer down" / "firewall" / "LAN-only off" from
+   stream-content problems.
+2. **first_frame** — call the existing ``capture_camera_frame_bytes``
+   pipeline (same code that powers /camera/snapshot) and verify at
+   least one JPEG comes back within the model's profile-derived
+   timeout. Combines auth + protocol handshake + first keyframe into
+   one stage because splitting RTSP's ``ffmpeg`` invocation is heavy
+   and the user-facing answer is the same either way: "the camera
+   itself isn't producing frames".
+
+Shortcut
+--------
+
+Most Bambu firmwares allow exactly one concurrent camera connection.
+Opening a fresh socket while a viewer is attached would kick them off
+(and trigger the same #1348 reconnect-storm pattern we built the fan-
+out broadcaster to prevent). When ``is_stream_active`` reports True
+AND a buffered frame is fresh (last 10 s), we short-circuit the test
+with ``live_stream_active`` and report success — the user is
+literally watching the camera right now, no test needed.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import time
+from dataclasses import dataclass, field
+
+from backend.app.services.camera import (
+    capture_camera_frame_bytes,
+    get_camera_port,
+    is_chamber_image_model,
+)
+from backend.app.services.camera_profiles import DEFAULT_PROFILE, get_camera_profile
+
+logger = logging.getLogger(__name__)
+
+
+# How long a live-stream buffered frame stays "fresh enough" to count as
+# proof that the camera works. Tuned conservatively — if the active
+# stream hasn't produced a frame in this window, run the real test
+# instead of trusting a possibly-stale buffer.
+_LIVE_FRAME_FRESHNESS_SECONDS = 10.0
+
+
+@dataclass
+class CameraDiagnoseStage:
+    """One step of the diagnostic. Status drives the green/red icon
+    the frontend renders next to the stage name."""
+
+    name: str  # "tcp_reachable" | "first_frame" | "live_stream_active"
+    status: str  # "ok" | "failed" | "skipped"
+    duration_ms: int = 0
+    # Optional machine-readable code for failures so the frontend can
+    # render a stage-specific hint without parsing free-text errors.
+    code: str | None = None
+
+
+@dataclass
+class CameraDiagnoseResult:
+    printer_id: int
+    protocol: str  # "rtsp" | "chamber_image"
+    port: int
+    # Whether this model's camera path uses the default profile or has
+    # an override entry in ``camera_profiles._PROFILES``. Useful for
+    # triage: tells us instantly whether the user is on a tuned model.
+    profile: str
+    overall_status: str  # "ok" | "failed"
+    stages: list[CameraDiagnoseStage] = field(default_factory=list)
+    # i18n key. Frontend maps to a translated remediation hint.
+    summary_code: str = ""
+
+    def to_dict(self) -> dict:
+        return {
+            "printer_id": self.printer_id,
+            "protocol": self.protocol,
+            "port": self.port,
+            "profile": self.profile,
+            "overall_status": self.overall_status,
+            "stages": [
+                {"name": s.name, "status": s.status, "duration_ms": s.duration_ms, "code": s.code} for s in self.stages
+            ],
+            "summary_code": self.summary_code,
+        }
+
+
+def _profile_label(model: str | None) -> str:
+    """Return ``"default"`` or the resolved model name when this model
+    has an override entry in :data:`camera_profiles._PROFILES`."""
+    profile = get_camera_profile(model)
+    if profile is DEFAULT_PROFILE:
+        return "default"
+    # Normalise via the same alias map the lookup uses. If the model
+    # resolves to a profile but the lookup is by alias (e.g. N7 → P2S),
+    # report the canonical display name.
+    from backend.app.services.camera_profiles import _MODEL_ALIASES, _PROFILES
+
+    key = (model or "").upper().strip()
+    key = _MODEL_ALIASES.get(key, key)
+    return key if key in _PROFILES else "default"
+
+
+async def _check_tcp_reachable(ip_address: str, port: int, timeout: float) -> CameraDiagnoseStage:
+    """Stage 1 — open a TCP socket to the camera port."""
+    started = time.monotonic()
+    try:
+        _, writer = await asyncio.wait_for(
+            asyncio.open_connection(ip_address, port),
+            timeout=timeout,
+        )
+        try:
+            writer.close()
+            await writer.wait_closed()
+        except OSError:
+            pass
+        return CameraDiagnoseStage(
+            name="tcp_reachable",
+            status="ok",
+            duration_ms=int((time.monotonic() - started) * 1000),
+        )
+    except asyncio.TimeoutError:
+        return CameraDiagnoseStage(
+            name="tcp_reachable",
+            status="failed",
+            duration_ms=int((time.monotonic() - started) * 1000),
+            code="tcp_timeout",
+        )
+    except (ConnectionRefusedError, OSError) as exc:
+        # ConnectionRefusedError = printer up, camera port closed (likely
+        # LAN-only off or developer mode off). Other OSError = host
+        # unreachable. We keep these separate codes so the frontend can
+        # surface a precise remediation hint.
+        is_refused = isinstance(exc, ConnectionRefusedError)
+        return CameraDiagnoseStage(
+            name="tcp_reachable",
+            status="failed",
+            duration_ms=int((time.monotonic() - started) * 1000),
+            code="tcp_refused" if is_refused else "tcp_unreachable",
+        )
+
+
+async def _check_first_frame(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    timeout: int,
+) -> CameraDiagnoseStage:
+    """Stage 2 — capture one frame end-to-end. Combines auth + protocol
+    handshake + first keyframe; either it works or it doesn't."""
+    started = time.monotonic()
+    try:
+        jpeg = await capture_camera_frame_bytes(
+            ip_address=ip_address,
+            access_code=access_code,
+            model=model,
+            timeout=timeout,
+        )
+    except Exception as exc:  # noqa: BLE001 — see camera_profiles.py rationale
+        # capture_camera_frame_bytes can raise from many layers (ffmpeg
+        # spawn, TLS proxy startup, asyncio.open_connection). For the
+        # user-facing answer, any exception during the capture path is
+        # "first frame failed" — drilling down is for the support log.
+        logger.warning("Camera diagnose first-frame capture raised: %s", exc)
+        return CameraDiagnoseStage(
+            name="first_frame",
+            status="failed",
+            duration_ms=int((time.monotonic() - started) * 1000),
+            code="capture_exception",
+        )
+    if jpeg:
+        return CameraDiagnoseStage(
+            name="first_frame",
+            status="ok",
+            duration_ms=int((time.monotonic() - started) * 1000),
+        )
+    return CameraDiagnoseStage(
+        name="first_frame",
+        status="failed",
+        duration_ms=int((time.monotonic() - started) * 1000),
+        code="no_frame",
+    )
+
+
+def _summary_for_stages(stages: list[CameraDiagnoseStage]) -> str:
+    """Pick the remediation key from the first failing stage's ``code``,
+    or ``all_ok`` when every stage passed."""
+    for stage in stages:
+        if stage.status != "failed":
+            continue
+        if stage.code == "tcp_timeout":
+            return "printer_unreachable"
+        if stage.code == "tcp_refused":
+            return "camera_port_closed"
+        if stage.code == "tcp_unreachable":
+            return "printer_unreachable"
+        if stage.code in ("no_frame", "capture_exception"):
+            return "no_frame"
+        return "unknown_failure"
+    return "all_ok"
+
+
+async def diagnose_camera(
+    ip_address: str,
+    access_code: str,
+    model: str | None,
+    printer_id: int,
+    *,
+    has_live_stream: bool = False,
+    live_frame_age_seconds: float | None = None,
+    tcp_timeout: float = 3.0,
+    capture_timeout: int = 15,
+) -> CameraDiagnoseResult:
+    """Run the camera diagnostic and return a structured result.
+
+    ``has_live_stream`` and ``live_frame_age_seconds`` are looked up
+    by the route handler from the active-stream registry (see the
+    docstring at the top of this file for why). When they indicate a
+    fresh frame is already buffered, the diagnostic short-circuits with
+    a ``live_stream_active`` stage and ``all_ok`` summary — real-world
+    proof of a working camera beats any synthetic test.
+    """
+    is_chamber = is_chamber_image_model(model)
+    protocol = "chamber_image" if is_chamber else "rtsp"
+    port = get_camera_port(model)
+
+    result = CameraDiagnoseResult(
+        printer_id=printer_id,
+        protocol=protocol,
+        port=port,
+        profile=_profile_label(model),
+        overall_status="ok",
+        stages=[],
+    )
+
+    # Shortcut: the camera is currently streaming with a fresh frame.
+    # Running the real diagnostic here would either kick the live
+    # viewer off (single-camera-connection printers) or block on the
+    # second-socket-refused timeout (#1348). Trust the live evidence.
+    if (
+        has_live_stream
+        and live_frame_age_seconds is not None
+        and 0 <= live_frame_age_seconds < _LIVE_FRAME_FRESHNESS_SECONDS
+    ):
+        result.stages.append(
+            CameraDiagnoseStage(
+                name="live_stream_active",
+                status="ok",
+                duration_ms=0,
+            )
+        )
+        result.summary_code = "live_stream_active_healthy"
+        return result
+
+    # Stage 1
+    tcp_stage = await _check_tcp_reachable(ip_address, port, tcp_timeout)
+    result.stages.append(tcp_stage)
+    if tcp_stage.status != "ok":
+        result.overall_status = "failed"
+        # Skip first_frame — without TCP there's no point spawning ffmpeg.
+        result.stages.append(CameraDiagnoseStage(name="first_frame", status="skipped", duration_ms=0))
+        result.summary_code = _summary_for_stages(result.stages)
+        return result
+
+    # Stage 2
+    frame_stage = await _check_first_frame(ip_address, access_code, model, capture_timeout)
+    result.stages.append(frame_stage)
+    if frame_stage.status != "ok":
+        result.overall_status = "failed"
+    result.summary_code = _summary_for_stages(result.stages)
+    return result

+ 111 - 0
backend/app/services/camera_profiles.py

@@ -0,0 +1,111 @@
+"""Per-printer-model camera tuning knobs.
+
+Bambuddy talks to multiple Bambu Lab printer models that all expose a
+camera but in subtly different ways:
+
+- **Chamber image** (port 6000, proprietary binary protocol) — A1, A1
+  Mini, P1P, P1S. Frame pacing and TLS quirks are firmware-driven and
+  don't go through ffmpeg.
+- **RTSPS** (port 322) — X1 series, X2D, H2 series, P2S. Wrapped by a
+  local TLS proxy + ffmpeg to MJPEG.
+
+The RTSPS path used to live with hard-coded module constants in
+``camera.py``: a single ``-probesize 32 -analyzeduration 0`` tuned for
+X1/H2 fast startup. That breaks the P2S on firmware 01.02.00.00, whose
+RTSP keyframe pacing is slow enough that ffmpeg can't lock onto the
+stream within 32 bytes and gives up with "not enough frames to estimate
+rate" (#1395 follow-up — Tschipel's reproduction).
+
+This module replaces those module constants with per-model
+:class:`CameraProfile` entries. Defaults match the historical pre-fix
+behaviour, so existing models (X1, H2, X2D, X1E) keep their fast-
+startup tuning unchanged. Quirky models override the relevant fields
+only — the P2S entry below is the first example.
+
+Adding a new model's quirk is a config edit (an entry in ``_PROFILES``
+plus the alias for its internal SSDP code if needed), not another
+hard-coded global constant.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+
+@dataclass(frozen=True)
+class CameraProfile:
+    """Tuning knobs for one printer model's camera path.
+
+    All defaults reflect the historical X1/H2 behaviour (fast startup,
+    minimal probing). Models with quirky firmware override individual
+    fields rather than re-defining the whole profile.
+    """
+
+    # --- RTSPS / ffmpeg path -------------------------------------------------
+    # ffmpeg's `-probesize` (bytes). Smaller = lower startup latency but
+    # less margin to lock onto a stream whose first keyframe is delayed
+    # or whose container metadata is incomplete. P2S 01.02.00.00 needs a
+    # full MB to lock; X1/H2 lock within ~32 bytes.
+    probesize: int = 32
+    # ffmpeg's `-analyzeduration` (microseconds). 0 = skip format
+    # analysis entirely. Same trade-off as probesize.
+    analyzeduration: int = 0
+    # Max consecutive ffmpeg respawns when the printer drops the RTSP
+    # session mid-stream. Some firmwares cut the stream after a few
+    # seconds (originally noted on P2S), so we transparently respawn
+    # to keep the MJPEG client alive.
+    rtsp_reconnect_max: int = 30
+    # Seconds between ffmpeg respawn attempts.
+    rtsp_reconnect_delay: float = 0.2
+
+    # --- Extra ffmpeg input args ---------------------------------------------
+    # Hook for future per-model knobs (e.g. `-fflags` overrides) without
+    # changing the dataclass shape. Tuple, not list, so the dataclass
+    # stays hashable / frozen-friendly.
+    extra_ffmpeg_input_args: tuple[str, ...] = field(default_factory=tuple)
+
+
+# ---------------------------------------------------------------------------
+# Profile registry
+# ---------------------------------------------------------------------------
+
+# Default profile = historical X1/H2 fast-startup behaviour. Used for
+# every RTSP-capable model that doesn't have an entry in ``_PROFILES``.
+DEFAULT_PROFILE = CameraProfile()
+
+# Per-model overrides. Keys are uppercase display names (e.g. "P2S")
+# AFTER alias normalisation, so internal SSDP codes ("N7") resolve via
+# ``_MODEL_ALIASES`` below.
+_PROFILES: dict[str, CameraProfile] = {
+    # P2S firmware 01.02.00.00 RTSP keyframe pacing is slow enough that
+    # ffmpeg's "32-byte probe + zero analyze" combo can't estimate the
+    # frame rate. ffmpeg's own stderr literally says "consider increasing
+    # probesize" (#1395 follow-up).
+    "P2S": CameraProfile(
+        probesize=1_000_000,
+        analyzeduration=500_000,
+    ),
+}
+
+# SSDP internal codes that should resolve to a display-name profile.
+# Display-name lookup is the canonical path; this just lets the camera
+# code pass through whatever ``Printer.model`` carries without each
+# call site needing to know the code→name map.
+_MODEL_ALIASES: dict[str, str] = {
+    "N7": "P2S",  # P2S internal SSDP code
+}
+
+
+def get_camera_profile(model: str | None) -> CameraProfile:
+    """Return the :class:`CameraProfile` for *model*, or the default.
+
+    ``model`` can be either a display name (e.g. ``"P2S"``) or an
+    internal SSDP code (e.g. ``"N7"``). Unknown models fall back to
+    :data:`DEFAULT_PROFILE` so the camera path is never blocked on a
+    missing entry.
+    """
+    if not model:
+        return DEFAULT_PROFILE
+    key = model.upper().strip()
+    key = _MODEL_ALIASES.get(key, key)
+    return _PROFILES.get(key, DEFAULT_PROFILE)

+ 71 - 61
backend/app/services/failure_analysis.py

@@ -4,12 +4,19 @@ from datetime import date, datetime, time, timedelta, timezone
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
 
 
 class FailureAnalysisService:
-    """Service for analyzing print failure patterns."""
+    """Service for analyzing print failure patterns.
+
+    Reads from print_log_entries (per-event data) rather than print_archives
+    so reprints contribute each run and orphan events (archive deleted, log
+    row survived via ON DELETE SET NULL) still count consistently with
+    Quick Stats. The archive-based predecessor diverged from Quick Stats
+    after #1378 moved the rest of the page to per-event aggregation.
+    """
 
     def __init__(self, db: AsyncSession):
         self.db = db
@@ -23,54 +30,54 @@ class FailureAnalysisService:
         project_id: int | None = None,
         created_by_id: int | None = None,
     ) -> dict:
-        """Analyze failure patterns across archives.
-
-        Args:
-            days: Number of days to analyze (fallback when no date range)
-            date_from: Start date filter (inclusive)
-            date_to: End date filter (inclusive)
-            printer_id: Optional filter by printer
-            project_id: Optional filter by project
-
-        Returns:
-            Dictionary with failure analysis results
-        """
+        """Analyze failure patterns across logged print events."""
         # Build base query — separate date vs non-date filters for trend reuse
         base_filter = []
         non_date_filter = []
         if date_from or date_to:
             if date_from:
                 dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
-                base_filter.append(PrintArchive.created_at >= dt_from)
+                base_filter.append(PrintLogEntry.created_at >= dt_from)
             if date_to:
                 dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
-                base_filter.append(PrintArchive.created_at <= dt_to)
-            # Compute effective span for trend
+                base_filter.append(PrintLogEntry.created_at <= dt_to)
             range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
             range_end = dt_to if date_to else datetime.now(timezone.utc)
             effective_days = max((range_end - range_start).days, 1)
         else:
             effective_days = days if days is not None else 30
             cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
-            base_filter.append(PrintArchive.created_at >= cutoff_date)
+            base_filter.append(PrintLogEntry.created_at >= cutoff_date)
         if printer_id:
-            non_date_filter.append(PrintArchive.printer_id == printer_id)
+            non_date_filter.append(PrintLogEntry.printer_id == printer_id)
+        # project_id is an archive-level concept; PrintLogEntry has no project
+        # link, so we resolve it by archive_id where present.
         if project_id:
-            non_date_filter.append(PrintArchive.project_id == project_id)
+            from backend.app.models.archive import PrintArchive
+
+            project_archive_ids = await self.db.execute(
+                select(PrintArchive.id).where(PrintArchive.project_id == project_id)
+            )
+            archive_ids = [row[0] for row in project_archive_ids.fetchall()]
+            if archive_ids:
+                non_date_filter.append(PrintLogEntry.archive_id.in_(archive_ids))
+            else:
+                # No archives in this project → nothing to count
+                non_date_filter.append(PrintLogEntry.id.is_(None))
         if created_by_id is not None:
             if created_by_id == -1:
-                non_date_filter.append(PrintArchive.created_by_id.is_(None))
+                non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
             else:
-                non_date_filter.append(PrintArchive.created_by_id == created_by_id)
+                non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
         base_filter.extend(non_date_filter)
 
         # Total counts
-        total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
+        total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
         total_prints = total_result.scalar() or 0
 
         failed_result = await self.db.execute(
-            select(func.count(PrintArchive.id)).where(
-                and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]))
+            select(func.count(PrintLogEntry.id)).where(
+                and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
             )
         )
         failed_prints = failed_result.scalar() or 0
@@ -80,38 +87,42 @@ class FailureAnalysisService:
         # Failures by reason
         reason_result = await self.db.execute(
             select(
-                PrintArchive.failure_reason,
-                func.count(PrintArchive.id).label("count"),
+                PrintLogEntry.failure_reason,
+                func.count(PrintLogEntry.id).label("count"),
             )
-            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
-            .group_by(PrintArchive.failure_reason)
-            .order_by(func.count(PrintArchive.id).desc())
+            .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
+            .group_by(PrintLogEntry.failure_reason)
+            .order_by(func.count(PrintLogEntry.id).desc())
         )
         failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
 
         # Failures by filament type
         filament_result = await self.db.execute(
             select(
-                PrintArchive.filament_type,
-                func.count(PrintArchive.id).label("count"),
+                PrintLogEntry.filament_type,
+                func.count(PrintLogEntry.id).label("count"),
             )
-            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
-            .group_by(PrintArchive.filament_type)
-            .order_by(func.count(PrintArchive.id).desc())
+            .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
+            .group_by(PrintLogEntry.filament_type)
+            .order_by(func.count(PrintLogEntry.id).desc())
         )
         failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
 
         # Failures by printer
         printer_result = await self.db.execute(
             select(
-                PrintArchive.printer_id,
-                func.count(PrintArchive.id).label("count"),
+                PrintLogEntry.printer_id,
+                func.count(PrintLogEntry.id).label("count"),
             )
             .where(
-                and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]), PrintArchive.printer_id.isnot(None))
+                and_(
+                    *base_filter,
+                    PrintLogEntry.status.in_(["failed", "aborted"]),
+                    PrintLogEntry.printer_id.isnot(None),
+                )
             )
-            .group_by(PrintArchive.printer_id)
-            .order_by(func.count(PrintArchive.id).desc())
+            .group_by(PrintLogEntry.printer_id)
+            .order_by(func.count(PrintLogEntry.id).desc())
         )
         failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
 
@@ -128,40 +139,39 @@ class FailureAnalysisService:
             failures_by_printer = {}
 
         # Failures by hour of day
-        failed_archives_result = await self.db.execute(
-            select(PrintArchive.started_at).where(
+        failed_events_result = await self.db.execute(
+            select(PrintLogEntry.started_at).where(
                 and_(
                     *base_filter,
-                    PrintArchive.status.in_(["failed", "aborted"]),
-                    PrintArchive.started_at.isnot(None),
+                    PrintLogEntry.status.in_(["failed", "aborted"]),
+                    PrintLogEntry.started_at.isnot(None),
                 )
             )
         )
         failures_by_hour = defaultdict(int)
-        for (started_at,) in failed_archives_result.fetchall():
+        for (started_at,) in failed_events_result.fetchall():
             if started_at:
                 hour = started_at.hour
                 failures_by_hour[hour] += 1
-        # Convert to dict with all 24 hours
         failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
 
         # Recent failures
         recent_result = await self.db.execute(
-            select(PrintArchive)
-            .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
-            .order_by(PrintArchive.created_at.desc())
+            select(PrintLogEntry)
+            .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
+            .order_by(PrintLogEntry.created_at.desc())
             .limit(10)
         )
         recent_failures = [
             {
-                "id": a.id,
-                "print_name": a.print_name or a.filename,
-                "failure_reason": a.failure_reason,
-                "filament_type": a.filament_type,
-                "printer_id": a.printer_id,
-                "created_at": a.created_at.isoformat() if a.created_at else None,
+                "id": e.archive_id,
+                "print_name": e.print_name,
+                "failure_reason": e.failure_reason,
+                "filament_type": e.filament_type,
+                "printer_id": e.printer_id,
+                "created_at": e.created_at.isoformat() if e.created_at else None,
             }
-            for a in recent_result.scalars().all()
+            for e in recent_result.scalars().all()
         ]
 
         # Failure rate trend (by week)
@@ -172,15 +182,15 @@ class FailureAnalysisService:
             week_start = week_end - timedelta(weeks=1)
 
             week_filter = [
-                PrintArchive.created_at >= week_start,
-                PrintArchive.created_at < week_end,
+                PrintLogEntry.created_at >= week_start,
+                PrintLogEntry.created_at < week_end,
                 *non_date_filter,
             ]
 
-            week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
+            week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
             week_failed = await self.db.execute(
-                select(func.count(PrintArchive.id)).where(
-                    and_(*week_filter, PrintArchive.status.in_(["failed", "aborted"]))
+                select(func.count(PrintLogEntry.id)).where(
+                    and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
                 )
             )
 

+ 4 - 0
backend/app/services/git_providers/forgejo.py

@@ -69,6 +69,7 @@ class ForgejoBackend(GiteaBackend):
 
             data = repo_resp.json()
             permissions = data.get("permissions", {})
+            is_private = bool(data.get("private", False))
 
             if not permissions.get("push", False):
                 return {
@@ -76,6 +77,7 @@ class ForgejoBackend(GiteaBackend):
                     "message": "Token does not have push permission to this repository",
                     "repo_name": data.get("full_name"),
                     "permissions": permissions,
+                    "is_private": is_private,
                 }
 
             return {
@@ -83,6 +85,7 @@ class ForgejoBackend(GiteaBackend):
                 "message": "Connection successful",
                 "repo_name": data.get("full_name"),
                 "permissions": permissions,
+                "is_private": is_private,
             }
 
         except Exception as e:
@@ -98,4 +101,5 @@ class ForgejoBackend(GiteaBackend):
                 "message": message,
                 "repo_name": None,
                 "permissions": None,
+                "is_private": None,
             }

+ 4 - 0
backend/app/services/git_providers/github.py

@@ -80,6 +80,7 @@ class GitHubBackend(GitProviderBackend):
 
             data = response.json()
             permissions = data.get("permissions", {})
+            is_private = bool(data.get("private", False))
 
             if not permissions.get("push", False):
                 return {
@@ -87,6 +88,7 @@ class GitHubBackend(GitProviderBackend):
                     "message": "Token does not have push permission to this repository",
                     "repo_name": data.get("full_name"),
                     "permissions": permissions,
+                    "is_private": is_private,
                 }
 
             return {
@@ -94,6 +96,7 @@ class GitHubBackend(GitProviderBackend):
                 "message": "Connection successful",
                 "repo_name": data.get("full_name"),
                 "permissions": permissions,
+                "is_private": is_private,
             }
 
         except Exception as e:
@@ -109,6 +112,7 @@ class GitHubBackend(GitProviderBackend):
                 "message": message,
                 "repo_name": None,
                 "permissions": None,
+                "is_private": None,
             }
 
     async def push_files(

+ 9 - 0
backend/app/services/git_providers/gitlab.py

@@ -83,12 +83,19 @@ class GitLabBackend(GitProviderBackend):
             group_level = (perms.get("group_access") or {}).get("access_level", 0)
             effective = max(project_level, group_level)
 
+            # GitLab uses visibility="private" / "internal" / "public". Both
+            # "internal" (signed-in users) and "public" are non-private for
+            # the purposes of this safety check.
+            visibility = (data.get("visibility") or "").lower()
+            is_private = visibility == "private"
+
             if effective < 30:  # Developer = 30, Maintainer = 40, Owner = 50
                 return {
                     "success": False,
                     "message": "Token requires Developer access or higher to push",
                     "repo_name": data.get("name_with_namespace"),
                     "permissions": perms,
+                    "is_private": is_private,
                 }
 
             return {
@@ -96,6 +103,7 @@ class GitLabBackend(GitProviderBackend):
                 "message": "Connection successful",
                 "repo_name": data.get("name_with_namespace"),
                 "permissions": perms,
+                "is_private": is_private,
             }
         except Exception as e:
             logger.error("GitLab connection test failed: %s", e)
@@ -104,6 +112,7 @@ class GitLabBackend(GitProviderBackend):
                 "message": f"Connection failed: {type(e).__name__}",
                 "repo_name": None,
                 "permissions": None,
+                "is_private": None,
             }
 
     async def push_files(

+ 45 - 0
backend/app/services/github_backup.py

@@ -141,6 +141,51 @@ class GitHubBackupService:
                 if not config.enabled:
                     return {"success": False, "message": "Backup is disabled", "log_id": None}
 
+                # Defense in depth: re-verify the repo is private before each
+                # push. The save endpoint already enforces this on every config
+                # change, but a user can flip a repo from private to public in
+                # GitHub's UI between configuration and the next scheduled run.
+                test_result = await self.test_connection(
+                    config.repository_url, config.access_token, provider=config.provider
+                )
+                if not test_result.get("success") or test_result.get("is_private") is not True:
+                    visibility_note = (
+                        "the target repository is no longer private"
+                        if test_result.get("is_private") is False
+                        else "could not confirm the target repository is private"
+                    )
+                    abort_message = (
+                        f"Backup aborted: {visibility_note}. Bambuddy backups carry credentials "
+                        "and are refused for any non-private target. Make the repository private "
+                        "to resume scheduled backups."
+                    )
+                    log = GitHubBackupLog(
+                        config_id=config_id,
+                        status="failed",
+                        trigger=trigger,
+                        completed_at=datetime.now(timezone.utc),
+                        error_message=abort_message,
+                    )
+                    db.add(log)
+                    config.last_backup_at = datetime.now(timezone.utc)
+                    config.last_backup_status = "failed"
+                    config.last_backup_message = abort_message
+                    if config.schedule_enabled:
+                        config.next_scheduled_run = self.calculate_next_run(config.schedule_type)
+                    await db.commit()
+                    await db.refresh(log)
+                    logger.warning(
+                        "Backup aborted for config %s: repo not private (is_private=%r, success=%r)",
+                        config_id,
+                        test_result.get("is_private"),
+                        test_result.get("success"),
+                    )
+                    return {
+                        "success": False,
+                        "message": abort_message,
+                        "log_id": log.id,
+                    }
+
                 # Create log entry
                 log = GitHubBackupLog(config_id=config_id, status="running", trigger=trigger)
                 db.add(log)

+ 19 - 12
backend/app/services/homeassistant.py

@@ -237,8 +237,15 @@ class HomeAssistantService:
     async def list_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
         """List available entities from HA.
 
-        By default, returns switch/light/input_boolean domains.
-        When search is provided, searches ALL entities by entity_id or friendly_name.
+        Always filters to switch/light/input_boolean/script — the only domains
+        the SmartPlugBase.ha_entity_id pattern accepts. When a search query is
+        provided it narrows the same domain-filtered list by entity_id or
+        friendly_name substring (case-insensitive).
+
+        Previously search bypassed the domain filter, which let users pick a
+        sensor.* or binary_sensor.* entity from the dropdown that the backend
+        schema would then reject with the cryptic Pydantic pattern error
+        (#1388). Picking what you can't save isn't a useful UX.
 
         Returns list of entity dicts with:
             - entity_id: str
@@ -246,8 +253,9 @@ class HomeAssistantService:
             - state: str
             - domain: str
         """
-        # Default domains for smart plug control
-        default_domains = {"switch", "light", "input_boolean", "script"}
+        # Allowed domains for smart plug control — must mirror the regex in
+        # backend/app/schemas/smart_plug.py:17 (SmartPlugBase.ha_entity_id).
+        allowed_domains = {"switch", "light", "input_boolean", "script"}
 
         try:
             async with httpx.AsyncClient(timeout=self.timeout) as client:
@@ -265,14 +273,13 @@ class HomeAssistantService:
                     domain = entity_id.split(".")[0] if "." in entity_id else ""
                     friendly_name = entity.get("attributes", {}).get("friendly_name", entity_id)
 
-                    # If searching, match against entity_id or friendly_name
-                    if search_lower:
-                        if search_lower not in entity_id.lower() and search_lower not in friendly_name.lower():
-                            continue
-                    else:
-                        # No search: filter to default domains only
-                        if domain not in default_domains:
-                            continue
+                    if domain not in allowed_domains:
+                        continue
+
+                    if search_lower and (
+                        search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
+                    ):
+                        continue
 
                     entities.append(
                         {

+ 32 - 17
backend/app/services/label_renderer.py

@@ -1,9 +1,12 @@
 """PDF spool label rendering.
 
-Five fixed templates:
+Six fixed templates:
 
-- ``ams_30x15``  — 30×15 mm single label, fits the popular Makerworld AMS
-  Filament Label Holder (model 752566). One label per page.
+- ``ams_holder_74x33`` — 74×33 mm single label, matches the printable label
+  STL bundled with the Makerworld AMS Filament Label Holder (model 752566).
+  Smaller variant — the visible window in the holder. One label per page.
+- ``ams_holder_75x55`` — 75×55 mm single label, fits the cardstock-insert
+  variant of the same holder. Roomier — swatch + QR + full text column.
 - ``box_40x30``  — 40×30 mm single label, common DK/Brother roll size and a
   good fit for filament-bag/storage-bin labels (#809 follow-up). Roomy
   layout — swatch, QR, full text column with hex code.
@@ -12,6 +15,10 @@ Five fixed templates:
 - ``avery_5160`` — US Letter sheet, 25.4×66.7 mm × 30 per sheet.
 - ``avery_l7160`` — A4 sheet, 38.1×63.5 mm × 21 per sheet.
 
+The legacy ``ams_30x15`` preset (#809) was incorrect — the original 30×15 mm
+dimension didn't fit any documented variant of model 752566. Replaced by the
+two ``ams_holder_*`` presets above (#1426).
+
 The renderer is decoupled from the Spool model: callers build a ``LabelData``
 list from whatever source (local DB, Spoolman, future) so the same code path
 works in both modes.
@@ -34,7 +41,14 @@ from reportlab.lib.pagesizes import A4, letter
 from reportlab.lib.units import mm
 from reportlab.pdfgen import canvas as rl_canvas
 
-TemplateName = Literal["ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160"]
+TemplateName = Literal[
+    "ams_holder_74x33",
+    "ams_holder_75x55",
+    "box_40x30",
+    "box_62x29",
+    "avery_5160",
+    "avery_l7160",
+]
 
 
 @dataclass
@@ -180,17 +194,17 @@ def _draw_label(c: rl_canvas.Canvas, x: float, y: float, w: float, h: float, dat
 
     Two layouts, picked by available height:
 
-    - **Tight** (h < 20 mm — AMS holder): swatch on the left, three lines of
-      text on the right (brand, material+subtype, big spool ID). No QR — at
-      30×15 mm there is not enough horizontal room for swatch + text + QR
-      without truncating away the user-need fields, and the AMS holder is an
-      at-a-glance identifier where the spool ID is the killer field. The
-      box-label and Avery templates carry the QR for the other use cases.
-
-    - **Roomy** (h >= 20 mm — box label, Avery sheets): swatch on the left,
-      QR on the right, multi-line text in the middle column. Large spool ID
-      anchored at bottom-left under the swatch so it stays readable when the
-      label is on a box on a shelf at arm's length.
+    - **Tight** (h < 20 mm): swatch on the left, three lines of text on the
+      right (brand, material+subtype, big spool ID). No QR — at very small
+      heights there is not enough horizontal room for swatch + text + QR
+      without truncating away the user-need fields. Kept as the safety
+      branch for any future ultra-small preset; the shipped templates all
+      land in the roomy layout below.
+
+    - **Roomy** (h >= 20 mm — AMS holder, box label, Avery sheets): swatch
+      on the left, QR on the right, multi-line text in the middle column.
+      Large spool ID anchored at bottom-left under the swatch so it stays
+      readable at arm's length.
     """
     pad = 1.2 * mm
     inner_x, inner_y = x + pad, y + pad
@@ -223,7 +237,7 @@ def _draw_label_tight(
     pad: float,
     data: LabelData,
 ) -> None:
-    """AMS-holder layout (e.g. 30×15 mm). Swatch + brand/material/hex/ID, no QR."""
+    """Tight layout (h < 20 mm). Swatch + brand/material/hex/ID, no QR."""
     swatch_w = min(inner_h, inner_w * 0.35)
     swatch_y = inner_y + (inner_h - swatch_w) / 2
     _draw_swatch(c, inner_x, swatch_y, swatch_w, swatch_w, data)
@@ -365,7 +379,8 @@ def _draw_label_roomy(
 
 # (label_w_mm, label_h_mm) for single-label-per-page templates.
 _SINGLE_LABEL_SIZES_MM: dict[str, tuple[float, float]] = {
-    "ams_30x15": (30.0, 15.0),
+    "ams_holder_74x33": (74.0, 33.0),
+    "ams_holder_75x55": (75.0, 55.0),
     "box_40x30": (40.0, 30.0),
     "box_62x29": (62.0, 29.0),
 }

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

@@ -173,6 +173,7 @@ class PrinterManager:
         self._on_ams_change: Callable[[int, list], None] | None = None
         self._on_layer_change: Callable[[int, int], None] | None = None
         self._on_bed_temp_update: Callable[[int, float], None] | None = None
+        self._on_drying_complete: Callable[[int, int], None] | None = None
         self._loop: asyncio.AbstractEventLoop | None = None
         # Track who started the current print (Issue #206)
         self._current_print_user: dict[int, dict] = {}  # {printer_id: {"user_id": int, "username": str}}
@@ -324,6 +325,14 @@ class PrinterManager:
         """Set callback for bed temperature updates. Receives (printer_id, bed_temp)."""
         self._on_bed_temp_update = callback
 
+    def set_drying_complete_callback(self, callback: Callable[[int, int], None]):
+        """Set callback for AMS drying completion events (#1349).
+
+        Receives ``(printer_id, ams_id)``. Fires once per falling edge of
+        ``dry_time`` (>0 → 0) for each AMS unit.
+        """
+        self._on_drying_complete = callback
+
     def _schedule_async(self, coro):
         """Schedule an async coroutine from a sync context.
 
@@ -375,6 +384,10 @@ class PrinterManager:
             if self._on_bed_temp_update:
                 self._schedule_async(self._on_bed_temp_update(printer_id, bed_temp))
 
+        def on_drying_complete(ams_id: int):
+            if self._on_drying_complete:
+                self._schedule_async(self._on_drying_complete(printer_id, ams_id))
+
         client = BambuMQTTClient(
             ip_address=printer.ip_address,
             serial_number=printer.serial_number,
@@ -386,6 +399,7 @@ class PrinterManager:
             on_ams_change=on_ams_change,
             on_layer_change=on_layer_change,
             on_bed_temp_update=on_bed_temp_update,
+            on_drying_complete=on_drying_complete,
         )
 
         client.connect()

+ 39 - 0
backend/app/services/smart_plug_manager.py

@@ -293,6 +293,45 @@ class SmartPlugManager:
             elif plug.off_delay_mode == "temperature":
                 self._schedule_temp_based_off(plug, printer_id, plug.off_temp_threshold)
 
+    async def on_drying_complete(self, printer_id: int, db: AsyncSession):
+        """Schedule turn-off for plugs flagged ``auto_off_after_drying`` when
+        an AMS drying cycle finishes on this printer (#1349).
+
+        Mirrors :meth:`on_print_complete` but uses the drying-specific
+        toggle and delay. Iterates every plug linked to the printer and
+        fires only on the ones the user has opted-in via the per-plug
+        toggle. Always uses the time-delay branch — temperature-based
+        cooldown is about the printer's hotend, which isn't meaningful
+        after a drying cycle (AMS chamber is the thing that's hot, and
+        Bambuddy doesn't track its temperature).
+        """
+        plugs = await self._get_plugs_for_printer(printer_id, db)
+        if not plugs:
+            return
+
+        for plug in plugs:
+            if not plug.enabled:
+                logger.debug("Smart plug '%s' is disabled, skipping drying auto-off", plug.name)
+                continue
+
+            if not plug.auto_off_after_drying:
+                logger.debug("Smart plug '%s' auto_off_after_drying is disabled, skipping", plug.name)
+                continue
+
+            # HA script entities can only be triggered, not turned off — same
+            # guard the print-finish path uses.
+            if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."):
+                logger.debug("Smart plug '%s' is a HA script entity, skipping drying auto-off", plug.name)
+                continue
+
+            logger.info(
+                "Drying completed on printer %s, scheduling turn-off for plug '%s' in %d min",
+                printer_id,
+                plug.name,
+                plug.off_delay_after_drying_minutes,
+            )
+            self._schedule_delayed_off(plug, printer_id, plug.off_delay_after_drying_minutes * 60)
+
     def _schedule_delayed_off(self, plug: "SmartPlug", printer_id: int, delay_seconds: int):
         """Schedule turn-off after delay."""
         # Cancel any existing task for this plug

+ 86 - 25
backend/app/services/spoolman.py

@@ -75,6 +75,25 @@ class SpoolmanClientError(Exception):
         self.response_text = response_text
 
 
+def _filament_subtype_part(name: str, material: str) -> str:
+    """Return the subtype portion of a filament name, lowercased.
+
+    Mirrors the read-side derivation in
+    ``backend/app/api/routes/_spoolman_helpers.py::_map_spoolman_spool``:
+    if the filament name starts with the material prefix (e.g. ``"PLA Glow"``
+    when material is ``"PLA"``), strip it; otherwise return the name as-is.
+
+    Used by ``find_or_create_filament`` so that an existing filament saved by
+    the AMS-sync path with name ``"Glow"`` still matches a user-driven edit
+    that composes ``"PLA Glow"`` (#1357).
+    """
+    s = (name or "").strip()
+    m = (material or "").strip()
+    if m and s.upper().startswith(m.upper() + " "):
+        return s[len(m) + 1 :].strip().lower()
+    return s.lower()
+
+
 class SpoolmanClient:
     """Client for interacting with Spoolman API."""
 
@@ -529,6 +548,23 @@ class SpoolmanClient:
         """Delete a spool from Spoolman."""
         await self._request_spool("DELETE", spool_id, operation="delete")
 
+    async def is_filament_shared(self, filament_id: int, exclude_spool_id: int) -> bool:
+        """True if any spool other than ``exclude_spool_id`` is linked to ``filament_id``.
+
+        Used by the spool-edit path to decide between PATCHing the existing
+        filament in place (singleton) and falling back to find_or_create
+        (shared — re-linking the spool is the only safe option). Includes
+        archived spools so a shared link doesn't suddenly look singleton just
+        because the sibling spool was archived.
+        """
+        spools = await self.get_all_spools(allow_archived=True)
+        for s in spools:
+            if s.get("id") == exclude_spool_id:
+                continue
+            if ((s.get("filament") or {}).get("id")) == filament_id:
+                return True
+        return False
+
     async def set_spool_archived(self, spool_id: int, archived: bool) -> dict:
         """Archive or restore a spool in Spoolman."""
         response = await self._request_spool(
@@ -539,6 +575,21 @@ class SpoolmanClient:
         )
         return response.json()
 
+    async def reset_spool_usage(self, spool_id: int) -> dict:
+        """Reset a spool's used_weight to 0 in Spoolman.
+
+        Used by the per-spool / bulk "Reset usage to 0" actions on the
+        Inventory page so the Total Consumed stat can be cleared without
+        touching the rest of the spool's data.
+        """
+        response = await self._request_spool(
+            "PATCH",
+            spool_id,
+            json_body={"used_weight": 0},
+            operation="reset-usage",
+        )
+        return response.json()
+
     async def update_spool_full(
         self,
         spool_id: int,
@@ -623,48 +674,58 @@ class SpoolmanClient:
         if brand:
             vendor_id = await self.find_or_create_vendor(brand)
 
+        # Normalised match keys (case-insensitive). Computed once outside the
+        # loop so the inner comparison stays simple.
+        composed_subtype = _filament_subtype_part(name, material)
+        material_norm = material.upper()
+        brand_norm = (brand or "").strip().lower()
+
         filaments = await self.get_filaments()
         for f in filaments:
             f_material = (f.get("material") or "").upper()
-            f_name = (f.get("name") or "").strip()
             f_color = (f.get("color_hex") or "").upper()[:6]
             f_vendor = f.get("vendor") or {}
             f_vendor_name = (f_vendor.get("name") or "").strip().lower()
 
-            material_match = f_material == material.upper()
-            name_match = f_name.lower() == name.lower()
+            material_match = f_material == material_norm
+            # Match on the subtype portion of the filament name. AMS-sync
+            # auto-create (the underscore-prefixed `_find_or_create_filament`
+            # used during MQTT tray import) stores the filament as just
+            # ``tray.tray_sub_brands`` — e.g. ``"Glow"`` — while the
+            # user-driven edit path here composes ``"<material> <subtype>"``
+            # — ``"PLA Glow"``. The old literal equality `f_name == name`
+            # failed to bridge the two shapes, so every edit fell through to
+            # `create_filament`, leaving a trail of duplicate filaments AND
+            # leaving the spool either still pointed at the old filament
+            # whose `color_name` never got patched, or pointed at a new
+            # filament with the colour while the inventory list kept
+            # showing the synth fallback from the old one (#1357).
+            f_subtype_part = _filament_subtype_part(f.get("name") or "", material)
+            name_match = f_subtype_part == composed_subtype
             color_match = f_color == color
-            vendor_match = (not brand) or f_vendor_name == (brand or "").strip().lower()
+            vendor_match = (not brand) or f_vendor_name == brand_norm
 
             if material_match and name_match and color_match and vendor_match:
-                # #1319: color_name is not part of the match key, but if the
-                # caller passed a value that differs from what's stored, update
-                # the filament — otherwise the user's edit is silently dropped
-                # and the inventory read falls back to subtype, making it look
-                # like color_name "reverts" to the subtype on every save.
-                # Convention: None = "don't touch"; "" = explicit clear; any
-                # other string = set/update.
-                if color_name is not None:
-                    existing = (f.get("color_name") or "").strip()
-                    requested = color_name.strip()
-                    if requested != existing:
-                        payload_value: str | None = requested if requested else None
-                        try:
-                            await self.patch_filament(f["id"], {"color_name": payload_value})
-                        except Exception as e:
-                            logger.warning(
-                                "Failed to update color_name on filament %s: %s",
-                                f["id"],
-                                e,
-                            )
+                # color_name is intentionally not part of the match key and
+                # is no longer patched onto the filament here: Spoolman 0.23.1
+                # has no `color_name` field on Filament (#1357 — confirmed
+                # against the FilamentUpdateParameters schema). The earlier
+                # #1319 fix tried to patch it and Spoolman silently dropped
+                # the key, which is exactly why the user's edit looked "not
+                # saved". The route now persists color_name via
+                # spool.extra.bambu_color_name (see _map_spoolman_spool for
+                # the read side); find_or_create_filament's only job is to
+                # resolve the right filament_id for the spool link.
                 return f["id"]
 
+        # color_name omitted: Spoolman has no such field on Filament (#1357);
+        # the user's color_name lands in spool.extra.bambu_color_name via the
+        # route after find_or_create_filament returns the new id.
         filament = await self.create_filament(
             name=name,
             vendor_id=vendor_id,
             material=material,
             color_hex=color,
-            color_name=color_name,
             weight=float(label_weight),
         )
         filament_id = filament.get("id")

+ 79 - 6
backend/app/services/virtual_printer/manager.py

@@ -160,6 +160,18 @@ class VirtualPrinterInstance:
         # Pending files for MQTT correlation
         self._pending_files: dict[str, Path] = {}
 
+        # Slicer-side print options captured from the MQTT `project_file`
+        # command, keyed by filename. Used by `_add_to_print_queue` so the
+        # queue item inherits the user's slicer-chosen timelapse / bed_leveling
+        # / flow_cali / vibration_cali / layer_inspect / use_ams toggles rather
+        # than falling back to the global `default_*` settings (#1403). FTP
+        # completes a few hundred ms before the slicer's MQTT `project_file`
+        # arrives, so the queue-add path waits briefly on the event below
+        # before reading the dict. Events are popped along with the options
+        # so the dict stays bounded.
+        self._slicer_print_options: dict[str, dict] = {}
+        self._slicer_print_options_events: dict[str, asyncio.Event] = {}
+
         # Per-instance services
         self._proxy: SlicerProxyManager | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
@@ -231,8 +243,24 @@ class VirtualPrinterInstance:
             self._mqtt.set_gcode_state("FINISH", filename=file_path.name, prepare_percent="100")
 
     async def on_print_command(self, filename: str, data: dict) -> None:
-        """Handle print command from MQTT."""
+        """Handle print command from MQTT.
+
+        Captures the slicer's project_file options (`timelapse`, `bed_leveling`,
+        `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`) so the
+        VP-queue path can inherit them when adding the item to the queue,
+        rather than falling back to the global default settings (#1403).
+        Only queue mode consumes the capture; immediate / review / proxy
+        modes ignore the print command, so we skip the stash there to keep
+        the dict from accumulating one entry per print over the VP's
+        uptime.
+        """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
+        if self.mode != "print_queue":
+            return
+        self._slicer_print_options[filename] = dict(data)
+        event = self._slicer_print_options_events.get(filename)
+        if event:
+            event.set()
 
     async def _archive_file(self, file_path: Path, source_ip: str) -> None:
         """Archive file immediately."""
@@ -344,6 +372,29 @@ class VirtualPrinterInstance:
                 pass
             return
 
+        # Wait briefly for the slicer's MQTT `project_file` command so the
+        # queue item can inherit the slicer-side print options the user
+        # picked (timelapse, bed_leveling, etc). Slicers send the FTP upload
+        # first and the MQTT command immediately after, so the typical lag
+        # is a few hundred ms; 2 s is conservative without making every
+        # VP-queue add visibly slow. Falls back to the global default_*
+        # settings if MQTT doesn't arrive in time (legacy behaviour for
+        # users on a slicer that doesn't send a print command). #1403.
+        # The wait is skipped when there's no MQTT server attached — covers
+        # unit tests that invoke `_add_to_print_queue` directly without
+        # going through `on_print_command`, so they don't pay the 2 s tax.
+        slicer_opts = self._slicer_print_options.pop(file_path.name, None)
+        if slicer_opts is None and self._mqtt is not None:
+            event = asyncio.Event()
+            self._slicer_print_options_events[file_path.name] = event
+            try:
+                await asyncio.wait_for(event.wait(), timeout=2.0)
+                slicer_opts = self._slicer_print_options.pop(file_path.name, None)
+            except asyncio.TimeoutError:
+                slicer_opts = None
+            finally:
+                self._slicer_print_options_events.pop(file_path.name, None)
+
         try:
             import json
 
@@ -360,14 +411,36 @@ class VirtualPrinterInstance:
                 # PrintQueueItem below would fall back to the column-level
                 # defaults and ignore the user's workflow preferences (#1235).
                 # Fallbacks match AppSettings defaults in schemas/settings.py.
+                # The slicer-side options captured above (if any) take
+                # precedence per-field over these defaults.
                 def _bool_setting(value: str | None, default: bool) -> bool:
                     return value.lower() == "true" if value is not None else default
 
-                bed_levelling = _bool_setting(await get_setting(db, "default_bed_levelling"), True)
-                flow_cali = _bool_setting(await get_setting(db, "default_flow_cali"), False)
-                vibration_cali = _bool_setting(await get_setting(db, "default_vibration_cali"), True)
-                layer_inspect = _bool_setting(await get_setting(db, "default_layer_inspect"), False)
-                timelapse = _bool_setting(await get_setting(db, "default_timelapse"), False)
+                def _slicer_or(field_mqtt: str, settings_default: bool) -> bool:
+                    """Slicer's MQTT value if present, else the settings default.
+
+                    Slicer payloads carry both bool and int (0/1) shapes
+                    depending on firmware family — coerce via bool() so
+                    `0`/`False` and `1`/`True` both work.
+                    """
+                    if slicer_opts is not None and field_mqtt in slicer_opts:
+                        return bool(slicer_opts[field_mqtt])
+                    return settings_default
+
+                # Note the MQTT field names differ from Bambuddy's column
+                # names: MQTT uses `bed_leveling` (single L) while the
+                # column / settings key use `bed_levelling` (double L).
+                bed_levelling = _slicer_or(
+                    "bed_leveling", _bool_setting(await get_setting(db, "default_bed_levelling"), True)
+                )
+                flow_cali = _slicer_or("flow_cali", _bool_setting(await get_setting(db, "default_flow_cali"), False))
+                vibration_cali = _slicer_or(
+                    "vibration_cali", _bool_setting(await get_setting(db, "default_vibration_cali"), True)
+                )
+                layer_inspect = _slicer_or(
+                    "layer_inspect", _bool_setting(await get_setting(db, "default_layer_inspect"), False)
+                )
+                timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
 
                 service = ArchiveService(db)
                 archive = await service.archive_print(

+ 120 - 2
backend/app/services/virtual_printer/mqtt_bridge.py

@@ -76,6 +76,110 @@ def _ip_to_uint32_le(ip_str: str) -> int:
     return parts[0] | (parts[1] << 8) | (parts[2] << 16) | (parts[3] << 24)
 
 
+def _merge_ams_dict(prev_ams: dict, new_ams: dict) -> dict:
+    """Merge a new ``ams`` blob from an incremental push onto the previous one.
+
+    Bambu firmware sends three shapes for the ``ams`` field on push_status:
+
+    1. Full pushall (after a printer reconnect or explicit pushall request):
+       ``{ams: [{id, tray: [{id, tray_type, ...}, ...]}, ...], ams_status, ams_exist_bits, ...}``
+       — every unit + every tray populated.
+
+    2. Status-only incremental: ``{ams_status: 1}`` or ``{humidity: 30}`` —
+       no ``ams`` array at all. Bambuddy logs these as "AMS partial update
+       (no tray data)" (#784 vintage).
+
+    3. Tray-targeted incremental during a print: ``{ams: [{id: 0, tray:
+       [{id: 0, state: 11}]}]}`` — only the units / trays whose state
+       changed.
+
+    Replacing the cached ``ams`` wholesale on shapes (2) and (3) is what
+    made the slicer "lose" AMS between pushalls and trip the symptom in
+    #1387: the slicer would see a stripped ``ams_status``-only blob and
+    fall back to its "no AMS" default render. This merge mirrors the
+    deep-merge logic in ``bambu_mqtt.py::_handle_ams_data`` at the bridge
+    layer so the slicer-facing cache always carries the latest known
+    coherent state.
+
+    Strategy:
+      - Shallow-merge top-level scalars: keys in ``new`` win; keys only
+        in ``prev`` are preserved.
+      - For the ``ams`` array (list of units): match by ``id``. Units
+        only in ``prev`` survive. Units in ``new`` overlay onto their
+        ``prev`` counterpart; same recursion applies to each unit's
+        ``tray`` array by tray ``id``.
+    """
+    merged = dict(prev_ams)
+    for k, v in new_ams.items():
+        if k != "ams":
+            merged[k] = v
+
+    prev_units = prev_ams.get("ams") if isinstance(prev_ams.get("ams"), list) else []
+    new_units = new_ams.get("ams") if isinstance(new_ams.get("ams"), list) else None
+    if new_units is None:
+        # Shape (2): no ``ams`` array in the incremental — keep prev's units.
+        if prev_units:
+            merged["ams"] = prev_units
+        return merged
+
+    prev_by_id = {u.get("id"): u for u in prev_units if isinstance(u, dict) and u.get("id") is not None}
+    merged_units: list = []
+    seen_ids: set = set()
+    for new_unit in new_units:
+        if not isinstance(new_unit, dict):
+            merged_units.append(new_unit)
+            continue
+        uid = new_unit.get("id")
+        prev_unit = prev_by_id.get(uid) if uid is not None else None
+        if prev_unit is None:
+            merged_units.append(new_unit)
+            if uid is not None:
+                seen_ids.add(uid)
+            continue
+        # Shallow-merge unit fields; preserve prev's trays not present in new.
+        merged_unit = dict(prev_unit)
+        for k, v in new_unit.items():
+            if k != "tray":
+                merged_unit[k] = v
+        new_trays = new_unit.get("tray") if isinstance(new_unit.get("tray"), list) else None
+        if new_trays is None:
+            # Unit-level partial — keep prev's tray list intact.
+            pass
+        else:
+            prev_trays = prev_unit.get("tray") if isinstance(prev_unit.get("tray"), list) else []
+            prev_trays_by_id = {t.get("id"): t for t in prev_trays if isinstance(t, dict) and t.get("id") is not None}
+            merged_trays: list = []
+            seen_tray_ids: set = set()
+            for new_tray in new_trays:
+                if not isinstance(new_tray, dict):
+                    merged_trays.append(new_tray)
+                    continue
+                tid = new_tray.get("id")
+                prev_tray = prev_trays_by_id.get(tid) if tid is not None else None
+                if prev_tray is None:
+                    merged_trays.append(new_tray)
+                else:
+                    merged_tray = dict(prev_tray)
+                    merged_tray.update(new_tray)
+                    merged_trays.append(merged_tray)
+                if tid is not None:
+                    seen_tray_ids.add(tid)
+            # Preserve prev trays not mentioned in the incremental.
+            for tid, prev_tray in prev_trays_by_id.items():
+                if tid not in seen_tray_ids:
+                    merged_trays.append(prev_tray)
+            merged_unit["tray"] = merged_trays
+        merged_units.append(merged_unit)
+        if uid is not None:
+            seen_ids.add(uid)
+    # Preserve prev units not mentioned in the incremental.
+    for uid, prev_unit in prev_by_id.items():
+        if uid not in seen_ids:
+            merged_units.append(prev_unit)
+    merged["ams"] = merged_units
+    return merged
+
+
 class MQTTBridge:
     """Per-VP MQTT fan-out between a real printer and slicers connected to a VP."""
 
@@ -296,8 +400,22 @@ class MQTTBridge:
             prev = self._latest_print_state
             if prev is not None:
                 for sticky_key in _SLICER_VISIBLE_STICKY_KEYS:
-                    if sticky_key not in new_state and sticky_key in prev:
-                        new_state[sticky_key] = prev[sticky_key]
+                    if sticky_key not in new_state:
+                        if sticky_key in prev:
+                            new_state[sticky_key] = prev[sticky_key]
+                        continue
+                    # Key IS in new_state — but firmware sends partial blobs
+                    # (status-only / tray-targeted) under the same key on
+                    # incremental updates, which would overwrite the cached
+                    # full blob and break the slicer's AMS render (#1387).
+                    # For `ams` specifically the deep-merge mirrors what
+                    # Bambuddy already does internally in `_handle_ams_data`.
+                    if (
+                        sticky_key == "ams"
+                        and isinstance(new_state.get("ams"), dict)
+                        and isinstance(prev.get("ams"), dict)
+                    ):
+                        new_state["ams"] = _merge_ams_dict(prev["ams"], new_state["ams"])
             self._latest_print_state = new_state
             return
 

+ 3 - 0
backend/tests/conftest.py

@@ -559,6 +559,9 @@ def archive_factory(db_session):
                 failure_reason=archive.failure_reason,
                 print_name=archive.print_name,
                 created_by_id=archive.created_by_id,
+                # Sync the event's created_at with the archive's so date-range
+                # filtered tests that backdate an archive still find its event.
+                created_at=archive.created_at,
             )
             db_session.add(run)
             await db_session.commit()

+ 82 - 10
backend/tests/integration/test_archive_purge_api.py

@@ -15,6 +15,8 @@ async def test_settings_defaults_when_unset(async_client: AsyncClient):
     body = resp.json()
     assert body["enabled"] is False
     assert body["days"] == 365
+    # #1390: default soft-delete — preserves Quick Stats contribution.
+    assert body["purge_stats"] is False
 
 
 @pytest.mark.asyncio
@@ -23,13 +25,13 @@ async def test_settings_roundtrip(async_client: AsyncClient):
     """PUT persists, GET returns the saved values, days is clamped."""
     resp = await async_client.put(
         "/api/v1/archives/purge/settings",
-        json={"enabled": True, "days": 180},
+        json={"enabled": True, "days": 180, "purge_stats": True},
     )
     assert resp.status_code == 200
-    assert resp.json() == {"enabled": True, "days": 180}
+    assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
 
     resp = await async_client.get("/api/v1/archives/purge/settings")
-    assert resp.json() == {"enabled": True, "days": 180}
+    assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
 
 
 @pytest.mark.asyncio
@@ -87,10 +89,12 @@ async def test_preview_ignores_recently_reprinted_archives(
 
 @pytest.mark.asyncio
 @pytest.mark.integration
-async def test_manual_purge_deletes_old_archives(
+async def test_manual_purge_soft_deletes_by_default(
     async_client: AsyncClient, archive_factory, printer_factory, db_session
 ):
-    """POST /archives/purge hard-deletes archives older than the threshold."""
+    """#1390: POST /archives/purge with no body flag soft-deletes — files
+    off disk, ``deleted_at`` set, archive row survives so Quick Stats keeps
+    every contribution. Matches the single-archive delete default from #1343."""
     from backend.app.models.archive import PrintArchive
 
     printer = await printer_factory()
@@ -108,18 +112,58 @@ async def test_manual_purge_deletes_old_archives(
         json={"older_than_days": 365},
     )
     assert resp.status_code == 200
-    assert resp.json()["deleted"] == 1
+    body = resp.json()
+    assert body["deleted"] == 1
+    assert body["purge_stats"] is False
+
+    db_session.expire_all()
+    # Old row still exists in DB but is soft-deleted.
+    old_row = await db_session.get(PrintArchive, old_id)
+    assert old_row is not None
+    assert old_row.deleted_at is not None
+    fresh_row = await db_session.get(PrintArchive, fresh_id)
+    assert fresh_row is not None
+    assert fresh_row.deleted_at is None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_manual_purge_hard_deletes_when_purge_stats_set(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1390: when ``purge_stats=true`` is sent in the body, the bulk purge
+    hard-deletes the archive AND the linked PrintLogEntry rows so the
+    contribution drops from /stats — matches the single-archive route's
+    ``?purge_stats=true`` semantics."""
+    from backend.app.models.archive import PrintArchive
+
+    printer = await printer_factory()
+    old = await archive_factory(printer.id, print_name="Old")
+    old_id = old.id
+    old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    resp = await async_client.post(
+        "/api/v1/archives/purge",
+        json={"older_than_days": 365, "purge_stats": True},
+    )
+    assert resp.status_code == 200
+    body = resp.json()
+    assert body["deleted"] == 1
+    assert body["purge_stats"] is True
 
-    # Old is gone, fresh remains.
     db_session.expire_all()
     assert await db_session.get(PrintArchive, old_id) is None
-    assert await db_session.get(PrintArchive, fresh_id) is not None
 
 
 @pytest.mark.asyncio
 @pytest.mark.integration
-async def test_auto_purge_runs_when_enabled(async_client: AsyncClient, archive_factory, printer_factory, db_session):
-    """With the toggle on, a stale archive is hard-deleted by the sweeper.
+async def test_auto_purge_soft_deletes_by_default(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1390: scheduled auto-purge defaults to soft-delete — Quick Stats
+    preserved unless the admin explicitly opts into hard-delete via the
+    settings toggle.
 
     ``async_client`` is included solely so its fixture activates the module-level
     ``async_session`` patches that let :meth:`purge_older_than`'s per-row
@@ -139,6 +183,34 @@ async def test_auto_purge_runs_when_enabled(async_client: AsyncClient, archive_f
     deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
     assert deleted >= 1
 
+    db_session.expire_all()
+    stale_row = await db_session.get(PrintArchive, stale_id)
+    assert stale_row is not None
+    assert stale_row.deleted_at is not None
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_auto_purge_hard_deletes_when_settings_opts_in(
+    async_client: AsyncClient, archive_factory, printer_factory, db_session
+):
+    """#1390: scheduled auto-purge honours the ``purge_stats`` setting —
+    when True the sweeper hard-deletes archive rows AND linked PrintLogEntry
+    rows, dropping every contribution from /stats."""
+    from backend.app.models.archive import PrintArchive
+    from backend.app.services.archive_purge import archive_purge_service
+
+    printer = await printer_factory()
+    stale = await archive_factory(printer.id, print_name="Stale")
+    stale_id = stale.id
+    stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
+    await db_session.commit()
+
+    await archive_purge_service.set_settings(db_session, enabled=True, days=365, purge_stats=True)
+
+    deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
+    assert deleted >= 1
+
     db_session.expire_all()
     assert await db_session.get(PrintArchive, stale_id) is None
 

+ 148 - 4
backend/tests/integration/test_archives_api.py

@@ -483,7 +483,10 @@ class TestArchivesSlimAPI:
         assert item["filament_used_grams"] == 50.0
         assert item["print_time_seconds"] == 3600
         assert item["cost"] == 1.50
-        assert item["quantity"] == 2
+        # quantity is per-event semantics now (each PrintLogEntry = one run);
+        # the archive's quantity field is no longer surfaced through this
+        # endpoint after the #1390 per-event migration.
+        assert item["quantity"] == 1
         assert "created_at" in item
 
         # Full archive fields must NOT be present
@@ -526,10 +529,13 @@ class TestArchivesSlimAPI:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_slim_actual_time_null_for_failed(
+    async def test_slim_actual_time_for_failed_includes_elapsed(
         self, async_client: AsyncClient, archive_factory, printer_factory, db_session
     ):
-        """Verify actual_time_seconds is null for non-completed prints."""
+        """Failed prints report measured elapsed time so Printer Stats By Time
+        matches Quick Stats Print Time (#1390). Previously this returned null
+        and the frontend fell back to the slicer estimate, double-counting the
+        unfinished portion of the print."""
         from datetime import datetime, timezone
 
         printer = await printer_factory()
@@ -544,7 +550,7 @@ class TestArchivesSlimAPI:
 
         assert response.status_code == 200
         item = response.json()[0]
-        assert item["actual_time_seconds"] is None
+        assert item["actual_time_seconds"] == 3600
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -585,6 +591,144 @@ class TestArchivesSlimAPI:
         assert response.status_code == 200
         assert len(response.json()) == 2
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_counts_reprints_as_separate_rows(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Reprints add events even though the archive row is overwritten (#1390).
+
+        Before the per-event migration, /archives/slim returned one row per
+        archive — so an archive that had been reprinted three times appeared
+        once and undercounted Filament Used / Cost / Time. The endpoint must
+        now return one row per logged event.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Reprinted Model",
+            filament_used_grams=50.0,
+            cost=1.50,
+        )
+        # archive_factory synthesizes one event; add two more to simulate
+        # the same archive being reprinted twice more.
+        for _ in range(2):
+            db_session.add(
+                PrintLogEntry(
+                    archive_id=archive.id,
+                    printer_id=archive.printer_id,
+                    status="completed",
+                    filament_type=archive.filament_type,
+                    filament_used_grams=archive.filament_used_grams,
+                    cost=archive.cost,
+                    print_name=archive.print_name,
+                )
+            )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 3, "Each reprint must contribute one row"
+        total_filament = sum(item["filament_used_grams"] or 0 for item in data)
+        assert total_filament == 150.0, "Sum across events must reflect all three runs"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_slim_includes_orphan_events(self, async_client: AsyncClient, printer_factory, db_session):
+        """Events whose archive was hard-deleted still appear (#1390).
+
+        After ON DELETE SET NULL the event row survives with archive_id=NULL.
+        The slim endpoint must keep counting it so Quick Stats and the
+        archive-iterating widgets stay aligned.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        db_session.add(
+            PrintLogEntry(
+                archive_id=None,
+                printer_id=printer.id,
+                status="completed",
+                filament_type="PETG",
+                filament_used_grams=25.0,
+                cost=0.75,
+                print_name="Orphaned Print",
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/slim")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["print_name"] == "Orphaned Print"
+        assert data[0]["filament_used_grams"] == 25.0
+        # print_time_seconds (sliced estimate) comes from the archive table,
+        # which orphans no longer have — must surface as null gracefully.
+        assert data[0]["print_time_seconds"] is None
+
+
+class TestFailureAnalysisAPI:
+    """Per-event failure analysis (#1390)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_failure_analysis_counts_reprints_and_orphans(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Failure analysis aggregates per event, not per archive.
+
+        Verifies the dual fix for #1390: a reprint that adds a second failed
+        event must count twice, and an orphan failed event (archive deleted)
+        must still appear in the totals.
+        """
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Failing Model",
+            status="failed",
+            failure_reason="filament_runout",
+        )
+        # Add a second failed event for the same archive (a reprint that also
+        # failed) and one orphan failed event (archive was deleted).
+        db_session.add(
+            PrintLogEntry(
+                archive_id=archive.id,
+                printer_id=printer.id,
+                status="failed",
+                failure_reason="filament_runout",
+                filament_type=archive.filament_type,
+                print_name=archive.print_name,
+            )
+        )
+        db_session.add(
+            PrintLogEntry(
+                archive_id=None,
+                printer_id=printer.id,
+                status="failed",
+                failure_reason="bed_adhesion",
+                filament_type="PETG",
+                print_name="Orphaned Failed Print",
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/archives/analysis/failures")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["total_prints"] == 3
+        assert result["failed_prints"] == 3
+        assert result["failures_by_reason"]["filament_runout"] == 2
+        assert result["failures_by_reason"]["bed_adhesion"] == 1
+
 
 class TestArchiveDataIntegrity:
     """Tests for archive data integrity."""

+ 50 - 0
backend/tests/integration/test_camera_api.py

@@ -190,6 +190,56 @@ class TestCameraAPI:
         result = response.json()
         assert result["success"] is False
 
+    # ========================================================================
+    # Camera Diagnose Endpoint (#1395 follow-up)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_diagnose_printer_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/camera/diagnose")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_camera_diagnose_returns_structured_result(self, async_client: AsyncClient, printer_factory):
+        """Endpoint returns the per-stage shape the frontend modal renders."""
+        from backend.app.services.camera_diagnose import (
+            CameraDiagnoseResult,
+            CameraDiagnoseStage,
+        )
+
+        printer = await printer_factory()
+
+        fake = CameraDiagnoseResult(
+            printer_id=printer.id,
+            protocol="rtsp",
+            port=322,
+            profile="P2S",
+            overall_status="failed",
+            stages=[
+                CameraDiagnoseStage(name="tcp_reachable", status="ok", duration_ms=12),
+                CameraDiagnoseStage(name="first_frame", status="failed", duration_ms=15123, code="no_frame"),
+            ],
+            summary_code="no_frame",
+        )
+        with patch(
+            "backend.app.services.camera_diagnose.diagnose_camera",
+            new_callable=AsyncMock,
+            return_value=fake,
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/diagnose")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["printer_id"] == printer.id
+        assert body["protocol"] == "rtsp"
+        assert body["profile"] == "P2S"
+        assert body["overall_status"] == "failed"
+        assert body["summary_code"] == "no_frame"
+        assert [s["name"] for s in body["stages"]] == ["tcp_reachable", "first_frame"]
+        assert body["stages"][1]["code"] == "no_frame"
+
     # ========================================================================
     # Camera Snapshot Endpoint
     # ========================================================================

+ 11 - 1
backend/tests/integration/test_external_folders_api.py

@@ -593,12 +593,22 @@ class TestExternalFolderWritableUpload:
         """DB row must have ``is_external=True`` and ``file_path`` = absolute external path,
         so scan-dedupe and deletion behaviour match scanned files."""
         import io
+        import zipfile
 
         from backend.app.models.library import LibraryFile
 
+        # #1401 hardened the library upload route to reject .3mf files that
+        # aren't valid ZIP containers. This test asserts external-folder
+        # DB shape, not the upload validator, so feed it a minimal real zip
+        # rather than placeholder bytes.
+        zip_buf = io.BytesIO()
+        with zipfile.ZipFile(zip_buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("placeholder.txt", "")
+        zip_buf.seek(0)
+
         response = await async_client.post(
             f"/api/v1/library/files?folder_id={writable_folder['id']}",
-            files={"file": ("model.3mf", io.BytesIO(b"x"), "application/octet-stream")},
+            files={"file": ("model.3mf", zip_buf, "application/octet-stream")},
         )
         assert response.status_code == 200
         file_id = response.json()["id"]

+ 216 - 0
backend/tests/integration/test_github_backup_api.py

@@ -1,9 +1,36 @@
 """Integration tests for GitHub Backup API endpoints."""
 
+from unittest.mock import AsyncMock, patch
+
 import pytest
 from httpx import AsyncClient
 
 
+@pytest.fixture(autouse=True)
+def _mock_private_repo_check():
+    """Default mock: test_connection returns success + confirmed private.
+
+    POST /config and PATCH /config now refuse to save when the target repo
+    isn't confirmed private (Bambuddy backups carry credentials — see
+    `_enforce_private_repo` in github_backup.py routes). The default mock
+    here keeps the existing test suite green; tests that need to exercise
+    the public / unknown-visibility branches override this fixture inline.
+    """
+    with patch(
+        "backend.app.services.github_backup.github_backup_service.test_connection",
+        new=AsyncMock(
+            return_value={
+                "success": True,
+                "message": "Connection successful",
+                "repo_name": "test/repo",
+                "permissions": {"push": True},
+                "is_private": True,
+            }
+        ),
+    ) as m:
+        yield m
+
+
 class TestGitHubBackupConfigAPI:
     """Integration tests for /api/v1/github-backup endpoints."""
 
@@ -234,6 +261,195 @@ class TestGitHubBackupConfigAPI:
         assert response.status_code == 404
 
 
+class TestGitHubBackupPrivateRepoGuard:
+    """Refuse to save a config when the target repository is not private.
+
+    Bambuddy backups contain MQTT credentials, HA/Prometheus tokens, the
+    Bambu Cloud email, and printer access codes via K-profiles — they must
+    never be pushed to a public or internal-visibility repository.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config_rejects_public_repo(self, async_client: AsyncClient):
+        """POST /config returns 400 when the connection test reports is_private=False."""
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": True,
+                    "message": "Connection successful",
+                    "repo_name": "test/public-repo",
+                    "permissions": {"push": True},
+                    "is_private": False,
+                }
+            ),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/config",
+                json={
+                    "repository_url": "https://github.com/test/public-repo",
+                    "access_token": "ghp_token",
+                    "branch": "main",
+                    "schedule_enabled": False,
+                    "schedule_type": "daily",
+                    "backup_kprofiles": True,
+                    "backup_cloud_profiles": True,
+                    "backup_settings": True,
+                    "enabled": True,
+                },
+            )
+
+        assert response.status_code == 400
+        assert "not private" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config_rejects_unknown_visibility(self, async_client: AsyncClient):
+        """POST /config returns 400 when is_private cannot be determined (None)."""
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": True,
+                    "message": "Connection successful",
+                    "repo_name": "test/repo",
+                    "permissions": {"push": True},
+                    "is_private": None,
+                }
+            ),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/config",
+                json={
+                    "repository_url": "https://github.com/test/repo",
+                    "access_token": "ghp_token",
+                    "branch": "main",
+                    "schedule_enabled": False,
+                    "schedule_type": "daily",
+                    "backup_kprofiles": True,
+                    "backup_cloud_profiles": True,
+                    "backup_settings": True,
+                    "enabled": True,
+                },
+            )
+
+        assert response.status_code == 400
+        assert "could not confirm" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_config_rejects_failed_connection(self, async_client: AsyncClient):
+        """POST /config returns 400 when the connection test itself fails."""
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": False,
+                    "message": "Invalid access token",
+                    "repo_name": None,
+                    "permissions": None,
+                    "is_private": None,
+                }
+            ),
+        ):
+            response = await async_client.post(
+                "/api/v1/github-backup/config",
+                json={
+                    "repository_url": "https://github.com/test/repo",
+                    "access_token": "bad-token",
+                    "branch": "main",
+                    "schedule_enabled": False,
+                    "schedule_type": "daily",
+                    "backup_kprofiles": True,
+                    "backup_cloud_profiles": True,
+                    "backup_settings": True,
+                    "enabled": True,
+                },
+            )
+
+        assert response.status_code == 400
+        assert "invalid access token" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_rejects_url_change_to_public_repo(self, async_client: AsyncClient):
+        """Changing the repository_url on an existing config re-checks privacy."""
+        # Initial create succeeds via the default autouse mock (private).
+        await async_client.post(
+            "/api/v1/github-backup/config",
+            json={
+                "repository_url": "https://github.com/test/private-repo",
+                "access_token": "ghp_token",
+                "branch": "main",
+                "schedule_enabled": False,
+                "schedule_type": "daily",
+                "backup_kprofiles": True,
+                "backup_cloud_profiles": True,
+                "backup_settings": True,
+                "enabled": True,
+            },
+        )
+
+        # Now try to switch to a public repo — must be rejected.
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=AsyncMock(
+                return_value={
+                    "success": True,
+                    "message": "Connection successful",
+                    "repo_name": "test/public-repo",
+                    "permissions": {"push": True},
+                    "is_private": False,
+                }
+            ),
+        ):
+            response = await async_client.patch(
+                "/api/v1/github-backup/config",
+                json={"repository_url": "https://github.com/test/public-repo"},
+            )
+
+        assert response.status_code == 400
+        assert "not private" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_skips_check_for_unrelated_fields(self, async_client: AsyncClient):
+        """PATCHing a non-target field (e.g. schedule) does NOT re-run the test.
+
+        Without this, every benign toggle would trigger a live API call.
+        """
+        await async_client.post(
+            "/api/v1/github-backup/config",
+            json={
+                "repository_url": "https://github.com/test/private-repo",
+                "access_token": "ghp_token",
+                "branch": "main",
+                "schedule_enabled": False,
+                "schedule_type": "daily",
+                "backup_kprofiles": True,
+                "backup_cloud_profiles": True,
+                "backup_settings": True,
+                "enabled": True,
+            },
+        )
+
+        # Replace the mock with one that would fail if called — proves the
+        # PATCH didn't hit test_connection for a schedule-only change.
+        mock = AsyncMock(side_effect=AssertionError("test_connection should not be called"))
+        with patch(
+            "backend.app.services.github_backup.github_backup_service.test_connection",
+            new=mock,
+        ):
+            response = await async_client.patch(
+                "/api/v1/github-backup/config",
+                json={"schedule_enabled": True},
+            )
+
+        assert response.status_code == 200
+        mock.assert_not_called()
+
+
 class TestGitHubBackupStatusAPI:
     """Integration tests for /api/v1/github-backup/status endpoint."""
 

+ 8 - 2
backend/tests/integration/test_labels.py

@@ -66,7 +66,13 @@ class TestLocalInventoryLabels:
     @pytest.mark.integration
     async def test_all_four_templates_succeed(self, async_client: AsyncClient, spool_factory):
         s = await spool_factory()
-        for template in ("ams_30x15", "box_62x29", "avery_5160", "avery_l7160"):
+        for template in (
+            "ams_holder_74x33",
+            "ams_holder_75x55",
+            "box_62x29",
+            "avery_5160",
+            "avery_l7160",
+        ):
             resp = await async_client.post(
                 "/api/v1/inventory/labels",
                 json={"spool_ids": [s.id], "template": template},
@@ -100,7 +106,7 @@ class TestLocalInventoryLabels:
         s = await spool_factory()
         resp = await async_client.post(
             "/api/v1/inventory/labels",
-            json={"spool_ids": [s.id, 99999], "template": "ams_30x15"},
+            json={"spool_ids": [s.id, 99999], "template": "ams_holder_74x33"},
         )
         assert resp.status_code == 404
         assert "99999" in resp.text

+ 110 - 0
backend/tests/integration/test_library_api.py

@@ -1112,3 +1112,113 @@ class TestLibraryPermissions:
         )
         # Viewers don't have delete_own or delete_all permissions
         assert response.status_code == 403
+
+
+class TestPrintFileUploadValidation:
+    """#1401: pre-flight rejection of unprintable uploads at the library +
+    archive routes. Smoke tests the shared ``validate_print_file_upload``
+    helper through both surfaces a user can reach with a drag-drop."""
+
+    def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
+        """Build a minimal-but-real zip with the gcode-3mf magic in it so
+        the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
+        buf = io.BytesIO()
+        with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(name, "; G-code\nG28\n")
+        return buf.getvalue()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
+        """``Foo.gcode`` direct uploads are blocked at the library route —
+        the dispatcher would otherwise append ``.3mf`` and ship raw gcode
+        to the printer as a fake 3MF."""
+        files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
+        response = await async_client.post("/api/v1/library/files", files=files)
+        assert response.status_code == 400
+        # Error message must name the actual remedy, not just say "invalid".
+        assert "gcode.3mf" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
+        """A ``.3mf`` upload whose body isn't a zip is rejected — covers
+        raw gcode renamed to .3mf, corrupted downloads, etc."""
+        files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
+        response = await async_client.post("/api/v1/library/files", files=files)
+        assert response.status_code == 400
+        assert "ZIP container" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
+        """The compound-extension ``.gcode.3mf`` case is gated by the same
+        zip-magic check — splitext returns just ``.3mf``, but the suffix
+        match covers both."""
+        files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
+        response = await async_client.post("/api/v1/library/files", files=files)
+        assert response.status_code == 400
+        assert "ZIP container" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
+        """A real ``.gcode.3mf`` zip uploads successfully — the existing
+        happy path is not regressed by the new validation."""
+        files = {
+            "file": (
+                "plate_1.gcode.3mf",
+                self._valid_3mf_bytes(),
+                "application/zip",
+            )
+        }
+        response = await async_client.post("/api/v1/library/files", files=files)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filename"] == "plate_1.gcode.3mf"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
+        """STL / image / other non-print uploads bypass the validator
+        entirely — Bambuddy is also a library, not just a print dispatcher."""
+        files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
+        response = await async_client.post(
+            "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
+        """``POST /archives/upload`` shares the same validator — covers the
+        manual archive-upload entry point too."""
+        files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
+        response = await async_client.post("/api/v1/archives/upload", files=files)
+        assert response.status_code == 400
+        assert "ZIP container" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
+        """The bulk-archive route reports validation failures per file and
+        continues processing the remaining items — one bad upload in a
+        10-file drag-drop must not abort the whole batch."""
+        good = self._valid_3mf_bytes()
+        bad = b"; raw gcode\nG28\n"
+        # httpx multipart with a list-of-tuples preserves order + same field name.
+        files = [
+            ("files", ("good.3mf", good, "application/zip")),
+            ("files", ("bad.3mf", bad, "application/octet-stream")),
+        ]
+        response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
+        assert response.status_code == 200
+        body = response.json()
+        # The bulk route's archive_print may still reject the "good" file
+        # downstream (no printer match, etc.) — we don't care about that
+        # here; what matters is the bad file lands in `errors` with the
+        # validator's message and the route didn't 500.
+        assert body["failed"] >= 1
+        bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
+        assert bad_errors, body
+        assert "ZIP container" in bad_errors[0]["error"]

+ 100 - 0
backend/tests/integration/test_printers_api.py

@@ -11,6 +11,23 @@ from httpx import AsyncClient
 from sqlalchemy import select
 
 
+@pytest.fixture(autouse=True)
+def _mock_printer_test_connection():
+    """Default mock: connection test returns success.
+
+    POST /printers/ now refuses to persist a printer when the MQTT
+    connection probe fails (would otherwise leave an empty card in the
+    dashboard for a mistyped access code). Existing tests assume the
+    save succeeds, so we mock the probe green by default; the failure
+    branch is exercised by a dedicated test below.
+    """
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.test_connection",
+        new=AsyncMock(return_value={"success": True, "state": "IDLE", "model": "X1C"}),
+    ) as m:
+        yield m
+
+
 class TestPrintersAPI:
     """Integration tests for /api/v1/printers/ endpoints."""
 
@@ -135,6 +152,44 @@ class TestPrintersAPI:
         # Should fail due to duplicate serial
         assert response.status_code in [400, 409, 422, 500]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_rejects_when_mqtt_probe_fails(self, async_client: AsyncClient, db_session):
+        """Wrong access code / unreachable IP must NOT persist the printer.
+
+        Regression: users were reporting empty / never-connecting printer
+        cards that traced back to a mistyped access code. The create route
+        now runs an MQTT probe up front and returns 400 if it fails — the
+        row is never written.
+        """
+        data = {
+            "name": "Bad Code Printer",
+            "serial_number": "00M09A999999999",
+            "ip_address": "192.168.1.250",
+            "access_code": "WRONG-CODE",
+            "is_active": True,
+            "model": "X1C",
+        }
+
+        with patch(
+            "backend.app.services.printer_manager.printer_manager.test_connection",
+            new=AsyncMock(return_value={"success": False, "state": None, "model": None}),
+        ):
+            response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 400
+        detail = response.json()["detail"]
+        # Backend returns a stable code for the frontend i18n layer to map;
+        # the message field is an English fallback for non-UI clients.
+        assert detail["code"] == "printer_connection_failed"
+        assert "connect" in detail["message"].lower()
+
+        # And critically: the printer row was never persisted.
+        from backend.app.models.printer import Printer
+
+        result = await db_session.execute(select(Printer).where(Printer.serial_number == "00M09A999999999"))
+        assert result.scalar_one_or_none() is None, "Failed-probe printer must not be persisted"
+
     # ========================================================================
     # Get single endpoint
     # ========================================================================
@@ -438,6 +493,51 @@ class TestPrintersAPI:
         assert response.status_code == 200
         assert response.content == b"PLATE_3_PNG"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cover_negative_cache_skips_repeat_ftp_fanout(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """#1420: when every FTP path returns 550 for the current subtask, the
+        next request for the same subtask must short-circuit to 404 instead of
+        replaying the 8-path FTP fan-out (which starves the printer's single
+        FTP socket and flooded the user's logs)."""
+        from unittest.mock import AsyncMock, MagicMock, patch
+
+        from backend.app.api.routes.printers import _cover_404_cache, _cover_cache
+        from backend.app.services.bambu_mqtt import PrinterState
+
+        printer = await printer_factory()
+
+        _cover_cache.pop(printer.id, None)
+        _cover_404_cache.pop(printer.id, None)
+
+        state = PrinterState()
+        state.connected = True
+        state.state = "RUNNING"
+        state.subtask_name = "OrphanPrint"
+        state.gcode_file = "OrphanPrint.3mf"
+
+        ftp_mock = AsyncMock(return_value=False)
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.printers.download_file_try_paths_async", ftp_mock),
+        ):
+            mock_pm.get_status = MagicMock(return_value=state)
+            mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+
+            r1 = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
+            r2 = await async_client.get(f"/api/v1/printers/{printer.id}/cover")
+
+        assert r1.status_code == 404
+        assert r2.status_code == 404
+        # First call retries internally; second call must short-circuit before FTP.
+        first_call_count = ftp_mock.await_count
+        assert first_call_count >= 1
+        # Second request didn't add to the count: the negative cache held.
+        assert ftp_mock.await_count == first_call_count
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_get_printer_status_omits_fila_switch_when_not_installed(

+ 209 - 0
backend/tests/integration/test_spool_reset_usage.py

@@ -0,0 +1,209 @@
+"""Reset-usage endpoint regressions (#1390 follow-up).
+
+The per-spool and bulk reset endpoints stamp `weight_used_baseline =
+weight_used` instead of zeroing `weight_used` directly. This decouples
+the resettable "Total Consumed" display (computed as
+`weight_used - weight_used_baseline`) from remaining
+(`label_weight - weight_used`), so resetting the counter does NOT
+inflate remaining back to label_weight (which is what the previous
+implementation did — see the report at the end of #1390).
+
+`weight_locked` is left alone in both modes; the spool keeps receiving
+AMS auto-sync updates from the next print onward.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.spool import Spool
+
+
+@pytest.fixture
+async def spool_factory(db_session: AsyncSession):
+    """Create a Spool with sensible defaults."""
+
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "weight_used": 0,
+            "weight_used_baseline": 0,
+            "weight_locked": False,
+        }
+        defaults.update(kwargs)
+        spool = Spool(**defaults)
+        db_session.add(spool)
+        await db_session.commit()
+        await db_session.refresh(spool)
+        return spool
+
+    return _create
+
+
+class TestResetSpoolUsage:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_stamps_baseline_without_touching_weight_used(
+        self, async_client: AsyncClient, spool_factory, db_session
+    ):
+        """Reset stamps baseline = weight_used; remaining stays the same.
+
+        Pre-bug behaviour zeroed weight_used and made
+        `label_weight - weight_used` (the displayed remaining) jump back
+        to label_weight — a 456 g spool would suddenly read 1000 g.
+        """
+        spool = await spool_factory(label_weight=1000, weight_used=456.0)
+
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["weight_used"] == 456.0, "weight_used must NOT be zeroed (drives remaining)"
+        assert body["weight_used_baseline"] == 456.0, "baseline must equal pre-reset weight_used"
+        # Displayed consumed = weight_used - baseline = 0
+        assert body["weight_used"] - body["weight_used_baseline"] == 0
+        # Displayed remaining = label_weight - weight_used = 544 (unchanged)
+        assert body["label_weight"] - body["weight_used"] == 544
+
+        await db_session.refresh(spool)
+        assert spool.weight_used == 456.0
+        assert spool.weight_used_baseline == 456.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_does_not_lock_spool(self, async_client: AsyncClient, spool_factory, db_session):
+        """Reset must leave weight_locked alone.
+
+        PATCH /spools/{id} auto-locks when weight_used is set explicitly;
+        the dedicated reset endpoint must NOT, because the user's intent
+        is "track fresh from zero", not "freeze at zero forever".
+        """
+        spool = await spool_factory(weight_used=100.0, weight_locked=False)
+
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        assert response.status_code == 200
+        await db_session.refresh(spool)
+        assert spool.weight_used == 100.0
+        assert spool.weight_used_baseline == 100.0
+        assert spool.weight_locked is False, "Reset must not auto-lock the spool"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_preserves_existing_lock(self, async_client: AsyncClient, spool_factory, db_session):
+        """If the user previously locked the spool, the lock is preserved."""
+        spool = await spool_factory(weight_used=500.0, weight_locked=True)
+
+        response = await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        assert response.status_code == 200
+        await db_session.refresh(spool)
+        assert spool.weight_used == 500.0
+        assert spool.weight_used_baseline == 500.0
+        assert spool.weight_locked is True, "Pre-existing lock must be preserved"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_then_print_advances_only_the_counter(
+        self, async_client: AsyncClient, spool_factory, db_session
+    ):
+        """After reset, a subsequent print delta shows up in the consumed
+        counter while remaining keeps decrementing normally.
+        """
+        spool = await spool_factory(label_weight=1000, weight_used=456.0)
+        await async_client.post(f"/api/v1/inventory/spools/{spool.id}/reset-usage")
+
+        # Simulate a 50g print (usage_tracker increments weight_used).
+        await db_session.refresh(spool)
+        spool.weight_used = (spool.weight_used or 0) + 50.0
+        await db_session.commit()
+
+        await db_session.refresh(spool)
+        consumed = spool.weight_used - spool.weight_used_baseline
+        remaining = spool.label_weight - spool.weight_used
+        assert consumed == 50.0, "Consumed counter reflects only post-reset usage"
+        assert remaining == 494, "Remaining tracks physical depletion across reset"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_404_for_missing_spool(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/inventory/spools/99999/reset-usage")
+        assert response.status_code == 404
+
+
+class TestBulkResetSpoolUsage:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_stamps_baseline_only_for_listed_spools(
+        self, async_client: AsyncClient, spool_factory, db_session
+    ):
+        """Only spools in the request are reset; others are untouched."""
+        target1 = await spool_factory(weight_used=100.0)
+        target2 = await spool_factory(weight_used=200.0)
+        untouched = await spool_factory(weight_used=300.0)
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": [target1.id, target2.id]},
+        )
+
+        assert response.status_code == 200
+        assert response.json() == {"reset": 2}
+
+        # The endpoint commits via its own session — expire our session so the
+        # next read pulls fresh values rather than the cached pre-reset state.
+        db_session.expire_all()
+        spools = (await db_session.execute(select(Spool))).scalars().all()
+        by_id = {s.id: s for s in spools}
+        assert by_id[target1.id].weight_used == 100.0
+        assert by_id[target1.id].weight_used_baseline == 100.0
+        assert by_id[target2.id].weight_used == 200.0
+        assert by_id[target2.id].weight_used_baseline == 200.0
+        assert by_id[untouched.id].weight_used == 300.0, "Spool not in request must keep its usage"
+        assert by_id[untouched.id].weight_used_baseline == 0, "Untouched baseline must stay at 0"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_rejects_empty_list(self, async_client: AsyncClient):
+        """Empty list must be rejected — guards against accidental wildcard wipes."""
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": []},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_rejects_missing_field(self, async_client: AsyncClient):
+        """Missing spool_ids field must be rejected."""
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_does_not_lock_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        """Bulk reset preserves weight_locked across all targets."""
+        unlocked = await spool_factory(weight_used=100.0, weight_locked=False)
+        locked = await spool_factory(weight_used=200.0, weight_locked=True)
+
+        response = await async_client.post(
+            "/api/v1/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": [unlocked.id, locked.id]},
+        )
+
+        assert response.status_code == 200
+        await db_session.refresh(unlocked)
+        await db_session.refresh(locked)
+        assert (
+            unlocked.weight_used == 100.0 and unlocked.weight_used_baseline == 100.0 and unlocked.weight_locked is False
+        )
+        assert locked.weight_used == 200.0 and locked.weight_used_baseline == 200.0 and locked.weight_locked is True

+ 197 - 30
backend/tests/integration/test_spoolman_inventory_api.py

@@ -63,9 +63,17 @@ def mock_spoolman_client():
     mock_client.set_spool_archived = AsyncMock(
         side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
     )
+    mock_client.reset_spool_usage = AsyncMock(return_value={**SAMPLE_SPOOLMAN_SPOOL, "used_weight": 0})
     mock_client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
     mock_client.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
     mock_client.find_or_create_filament = AsyncMock(return_value=7)
+    mock_client.find_or_create_vendor = AsyncMock(return_value=3)
+    mock_client.patch_filament = AsyncMock(return_value={"id": 7})
+    # Default to singleton (only this spool uses the filament) so edits
+    # exercise the new in-place-PATCH path; tests that need the shared
+    # branch override this on the fly.
+    mock_client.is_filament_shared = AsyncMock(return_value=False)
+    mock_client.ensure_extra_field = AsyncMock(return_value=True)
 
     with (
         patch(
@@ -323,39 +331,114 @@ class TestSpoolmanInventoryCRUD:
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_with_explicit_null_color_name_clears(
+    async def test_update_noop_metadata_reuses_filament(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """#1319 follow-up: explicit color_name=null in the PATCH body means
-        "clear" — route translates it to "" so find_or_create_filament patches
-        the matched filament with color_name=None."""
-        payload = {"color_name": None}
+        """#1357 follow-up: an edit that doesn't touch any filament-shaping
+        field (only weight_used / note / color_name) must NOT hit
+        find_or_create_filament OR patch_filament — the link stays put and
+        the filament catalogue is left alone."""
+        payload = {"note": "just a note change", "weight_used": 50.0}
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
+        assert response.status_code == 200
+        mock_spoolman_client.find_or_create_filament.assert_not_called()
+        mock_spoolman_client.patch_filament.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_singleton_filament_patches_in_place(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """#1357 follow-up: when the linked filament is only used by the
+        spool being edited (singleton), changing the subtype must PATCH that
+        filament in place — NOT create a new filament and orphan the old
+        one. This is the exact failure the reporter showed: editing Subtype
+        "Red" → "Basic" minted a new "PETG Basic" filament every time.
+        """
+        # Sample filament is "PLA Basic"; flip to "Matte" so the metadata
+        # actually changes and the singleton path engages.
+        payload = {"subtype": "Matte"}
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
+        assert response.status_code == 200
+        # Singleton path: PATCH the existing filament, do NOT find_or_create.
+        mock_spoolman_client.patch_filament.assert_called_once()
+        mock_spoolman_client.find_or_create_filament.assert_not_called()
+        # PATCH targets the spool's current filament (id=7) with the new name.
+        call_args = mock_spoolman_client.patch_filament.call_args
+        assert call_args.args[0] == 7
+        assert call_args.args[1]["name"] == "PLA Matte"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_shared_filament_falls_back_to_find_or_create(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """#1357 follow-up: when the linked filament is shared with another
+        spool, PATCHing in place would silently rewrite the sibling's
+        metadata too. Fall back to find_or_create — only this spool's
+        filament_id moves."""
+        mock_spoolman_client.is_filament_shared.return_value = True
+        payload = {"subtype": "Matte"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
         mock_spoolman_client.find_or_create_filament.assert_called_once()
-        kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
-        assert kwargs["color_name"] == ""
+        mock_spoolman_client.patch_filament.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_with_explicit_null_color_name_clears_extra(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """#1357: explicit color_name=null means "clear". The route writes a
+        JSON-encoded empty string to spool.extra.bambu_color_name so the read
+        path falls back to the synth value next time."""
+        payload = {"color_name": None}
+        response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
+        assert response.status_code == 200
+        mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
+        mock_spoolman_client.merge_spool_extra.assert_called_once()
+        _, kwargs = mock_spoolman_client.merge_spool_extra.call_args
+        # First positional arg is spool_id; second is the extra-dict patch.
+        args = mock_spoolman_client.merge_spool_extra.call_args.args
+        extra_patch = args[1] if len(args) > 1 else kwargs.get("new_fields", {})
+        import json as _json
+
+        assert _json.loads(extra_patch["bambu_color_name"]) == ""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_without_color_name_keeps_current(
+    async def test_update_without_color_name_skips_extra_write(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """#1319 follow-up: when color_name is omitted from the PATCH body the
-        current value is kept — None passed to find_or_create_filament means
-        "don't touch"."""
+        """#1357: when color_name is omitted from the PATCH body the extra
+        write is skipped entirely — no merge_spool_extra call, no ensure_extra
+        call for bambu_color_name. Only fields the request explicitly set go
+        through the extra round-trip."""
         payload = {"note": "only updating note"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        kwargs = mock_spoolman_client.find_or_create_filament.call_args.kwargs
-        # SAMPLE_SPOOLMAN_SPOOL has no color_name field, so cur fallback is None.
-        assert kwargs["color_name"] is None
+        # No call should target bambu_color_name when color_name wasn't in the body.
+        color_name_calls = [
+            c
+            for c in mock_spoolman_client.ensure_extra_field.call_args_list
+            if c.args and c.args[0] == "bambu_color_name"
+        ]
+        assert color_name_calls == []
 
     @pytest.mark.asyncio
     @pytest.mark.integration
@@ -475,6 +558,69 @@ class TestSpoolmanInventoryCRUD:
         assert response.status_code == 200
         mock_spoolman_client.set_spool_archived.assert_called_once_with(42, archived=False)
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_spool_usage(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """POST /spoolman/inventory/spools/{id}/reset-usage zeroes used_weight in Spoolman.
+
+        Parity with internal mode (#1390): the InventorySpool response
+        carries `weight_used = label - remaining` and
+        `weight_used_baseline = weight_used - real_used_weight`, so the
+        displayed consumed counter (weight_used - baseline) reads 0
+        while remaining (= label - weight_used) preserves Spoolman's
+        independent remaining_weight field.
+        """
+        response = await async_client.post("/api/v1/spoolman/inventory/spools/42/reset-usage")
+
+        assert response.status_code == 200
+        body = response.json()
+        # Sample spool: label=1000, remaining=750, used_weight=0 after Spoolman reset.
+        assert body["weight_used"] == 250.0, "synthetic weight_used = label - remaining"
+        assert body["weight_used_baseline"] == 250.0, "baseline absorbs the reset"
+        assert body["weight_used"] - body["weight_used_baseline"] == 0, "displayed consumed = 0"
+        assert body["label_weight"] - body["weight_used"] == 750, "remaining unchanged"
+        mock_spoolman_client.reset_spool_usage.assert_called_once_with(42)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_spool_usage(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """Bulk endpoint resets each listed spool and returns the count."""
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": [1, 2, 3]},
+        )
+
+        assert response.status_code == 200
+        assert response.json() == {"reset": 3}
+        assert mock_spoolman_client.reset_spool_usage.call_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_reset_rejects_empty_list(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+    ):
+        """Empty list must be rejected — guards against accidental wildcard wipes."""
+        response = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/reset-usage-bulk",
+            json={"spool_ids": []},
+        )
+
+        assert response.status_code == 400
+        mock_spoolman_client.reset_spool_usage.assert_not_called()
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_sync_weight(
@@ -1117,17 +1263,26 @@ class TestStorageLocationPassthrough:
 
 
 class TestColorNamePassthrough:
-    """color_name is forwarded to find_or_create_filament on create and update (B6 / T5)."""
+    """color_name persistence via spool.extra.bambu_color_name (#1357).
+
+    Spoolman 0.23.1 has no `color_name` field on Filament, so Bambuddy owns
+    the round-trip via the spool's extra dict — same shape as the existing
+    bambu_slicer_filament storage. These tests pin that the create/update
+    routes register the extra field and write to merge_spool_extra, NOT to
+    find_or_create_filament's color_name parameter.
+    """
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_create_passes_color_name_to_filament(
+    async def test_create_writes_color_name_to_spool_extra(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """color_name from the create payload is forwarded to find_or_create_filament."""
+        """color_name from create payload lands in spool.extra.bambu_color_name."""
+        import json as _json
+
         payload = {
             "material": "PLA",
             "label_weight": 1000,
@@ -1136,41 +1291,53 @@ class TestColorNamePassthrough:
         }
         response = await async_client.post("/api/v1/spoolman/inventory/spools", json=payload)
         assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        assert kwargs.get("color_name") == "Bambu Green"
+        mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
+        mock_spoolman_client.merge_spool_extra.assert_called_once()
+        args = mock_spoolman_client.merge_spool_extra.call_args.args
+        extra_patch = args[1]
+        assert _json.loads(extra_patch["bambu_color_name"]) == "Bambu Green"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_passes_color_name_to_filament(
+    async def test_update_writes_color_name_to_spool_extra(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """color_name from the update payload is forwarded to find_or_create_filament."""
+        """color_name from update payload lands in spool.extra.bambu_color_name —
+        this is the #1357 reproduction: previously the value went to
+        filament.color_name which Spoolman silently dropped."""
+        import json as _json
+
         payload = {"color_name": "Jade White"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        mock_spoolman_client.find_or_create_filament.assert_called_once()
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        assert kwargs.get("color_name") == "Jade White"
+        mock_spoolman_client.ensure_extra_field.assert_any_call("bambu_color_name")
+        mock_spoolman_client.merge_spool_extra.assert_called_once()
+        args = mock_spoolman_client.merge_spool_extra.call_args.args
+        extra_patch = args[1]
+        assert _json.loads(extra_patch["bambu_color_name"]) == "Jade White"
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_update_omits_color_name_when_not_provided(
+    async def test_update_omits_color_name_skips_extra_write(
         self,
         async_client: AsyncClient,
         spoolman_settings,
         mock_spoolman_client,
     ):
-        """When color_name is not in the PATCH payload, the existing filament color_name is used."""
+        """When color_name is absent from the PATCH body, the route must not
+        write to spool.extra at all (preserves any existing value)."""
         payload = {"note": "no color_name here"}
         response = await async_client.patch("/api/v1/spoolman/inventory/spools/42", json=payload)
         assert response.status_code == 200
-        _, kwargs = mock_spoolman_client.find_or_create_filament.call_args
-        # color_name falls back to current filament's color_name (which is None in test fixture)
-        assert kwargs.get("color_name") is None
+        color_name_calls = [
+            c
+            for c in mock_spoolman_client.ensure_extra_field.call_args_list
+            if c.args and c.args[0] == "bambu_color_name"
+        ]
+        assert color_name_calls == []
 
 
 class TestSpoolmanInventoryAuth:

+ 62 - 0
backend/tests/integration/test_updates_api.py

@@ -181,6 +181,68 @@ class TestUpdatesAPI:
         assert body["is_docker"] is True
         assert body["update_method"] == "docker"
 
+    @pytest.mark.asyncio
+    async def test_check_backs_off_after_github_rate_limit(self, async_client: AsyncClient):
+        """#1420: once GitHub returns 403 with X-RateLimit-Remaining=0, the
+        next call must short-circuit on the backoff window instead of hitting
+        api.github.com again. Otherwise the user's logs flood with rate-limit
+        errors and Bambuddy keeps adding to whatever throttle GitHub applies."""
+        import time
+
+        import httpx as _httpx
+
+        import backend.app.api.routes.updates as updates_module
+
+        # Reset module-level backoff state between tests.
+        updates_module._github_rate_limit_until = 0.0
+
+        # Future reset time, ~10 minutes ahead — the backoff window we expect.
+        future_reset = time.time() + 600
+
+        class _RateLimitedResp:
+            status_code = 403
+            headers = {
+                "X-RateLimit-Remaining": "0",
+                "X-RateLimit-Reset": str(int(future_reset)),
+            }
+            text = "API rate limit exceeded"
+
+            def raise_for_status(self):
+                raise _httpx.HTTPStatusError("403", request=None, response=self)
+
+            def json(self):
+                return {"message": "API rate limit exceeded"}
+
+        call_counter = {"n": 0}
+
+        class _FakeClient:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                return None
+
+            async def get(self, *_, **__):
+                call_counter["n"] += 1
+                return _RateLimitedResp()
+
+        try:
+            with patch.object(_httpx, "AsyncClient", _FakeClient):
+                first = await async_client.get("/api/v1/updates/check")
+                second = await async_client.get("/api/v1/updates/check")
+        finally:
+            updates_module._github_rate_limit_until = 0.0
+
+        # First request reached httpx; second short-circuited on the backoff.
+        assert call_counter["n"] == 1
+
+        first_body = first.json()
+        second_body = second.json()
+        assert "rate limit" in (first_body.get("error") or "").lower()
+        assert "rate limit" in (second_body.get("error") or "").lower()
+        # Backoff window roughly matches the X-RateLimit-Reset header.
+        assert second_body.get("retry_after_seconds", 0) > 0
+
     def test_parse_version(self):
         from backend.app.api.routes.updates import parse_version
 

+ 130 - 0
backend/tests/integration/test_webhook_start_print.py

@@ -0,0 +1,130 @@
+"""Regression tests for the webhook `/printer/{id}/start` route.
+
+The previous implementation called `printer_manager.start_print()` directly
+with `queue_item.archive_id` (an int) as the filename arg and no print
+options, and used `await` on a non-async function. That route 500'd on
+every invocation. The fix mirrors `POST /print-queue/{item_id}/start`:
+clear the next pending item's `manual_start` so the scheduler picks it up
+with the queue's stored options (timelapse, bed_levelling, etc.) intact.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def api_key_data(async_client: AsyncClient, db_session):
+    """Create an API key with control_printer permission."""
+    from backend.app.core.auth import generate_api_key
+    from backend.app.models.api_key import APIKey
+
+    full_key, key_hash, key_prefix = generate_api_key()
+    api_key = APIKey(
+        name="webhook-test-key",
+        key_hash=key_hash,
+        key_prefix=key_prefix,
+        can_queue=True,
+        can_control_printer=True,
+        can_read_status=True,
+        enabled=True,
+    )
+    db_session.add(api_key)
+    await db_session.commit()
+    return full_key
+
+
+@pytest.fixture
+async def printer_with_queue(db_session):
+    """Create a printer and a pending queue item with manual_start=True."""
+    from backend.app.models.print_queue import PrintQueueItem
+    from backend.app.models.printer import Printer
+
+    printer = Printer(
+        name="WebhookTest",
+        ip_address="192.168.1.42",
+        access_code="12345678",
+        serial_number="00M00A000000000",
+        model="P1S",
+    )
+    db_session.add(printer)
+    await db_session.commit()
+
+    item = PrintQueueItem(
+        printer_id=printer.id,
+        position=1,
+        status="pending",
+        manual_start=True,
+        timelapse=True,
+        bed_levelling=True,
+        flow_cali=False,
+        vibration_cali=True,
+        layer_inspect=False,
+        use_ams=True,
+    )
+    db_session.add(item)
+    await db_session.commit()
+    return printer, item
+
+
+class TestWebhookStartPrint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clears_manual_start_on_next_pending_item(
+        self, async_client: AsyncClient, db_session, api_key_data, printer_with_queue
+    ):
+        """The webhook flips manual_start to False so the scheduler picks it up.
+
+        Pre-fix the route called `printer_manager.start_print()` directly
+        with no options and `archive_id` (int) as the filename — 500'd on
+        every invocation. Now it mirrors the regular `/print-queue/{id}/start`
+        affordance: scheduler dispatch handles FTP upload and all print
+        options via the queue's stored fields.
+        """
+        printer, item = printer_with_queue
+
+        resp = await async_client.post(
+            f"/api/v1/webhook/printer/{printer.id}/start",
+            headers={"X-API-Key": api_key_data},
+        )
+
+        assert resp.status_code == 200, resp.text
+        assert resp.json()["queue_item_id"] == item.id
+
+        await db_session.refresh(item)
+        assert item.manual_start is False, "manual_start must be cleared so scheduler dispatches"
+        # Stored options must be untouched so the scheduler picks the user's choice.
+        assert item.timelapse is True
+        assert item.bed_levelling is True
+        assert item.vibration_cali is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_404_when_no_pending_items(self, async_client: AsyncClient, db_session, api_key_data):
+        from backend.app.models.printer import Printer
+
+        printer = Printer(
+            name="EmptyQueue",
+            ip_address="192.168.1.43",
+            access_code="12345678",
+            serial_number="00M00A000000001",
+            model="P1S",
+        )
+        db_session.add(printer)
+        await db_session.commit()
+
+        resp = await async_client.post(
+            f"/api/v1/webhook/printer/{printer.id}/start",
+            headers={"X-API-Key": api_key_data},
+        )
+
+        assert resp.status_code == 404
+        assert "No pending prints" in resp.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_returns_404_when_printer_does_not_exist(self, async_client: AsyncClient, api_key_data):
+        resp = await async_client.post(
+            "/api/v1/webhook/printer/99999/start",
+            headers={"X-API-Key": api_key_data},
+        )
+        assert resp.status_code == 404

+ 39 - 0
backend/tests/unit/services/test_background_dispatch.py

@@ -212,6 +212,45 @@ def test_is_sliced_file_recognizes_supported_extensions():
     assert BackgroundDispatchService._is_sliced_file("part.3mf") is False
 
 
+def test_dispatch_option_defaults_align_with_request_schema_defaults():
+    """The `job.options.get("<field>", <default>)` calls in the dispatch
+    loop must use the same default as the Pydantic request schema. If a
+    field is missing from options (e.g. an internal caller bypassing the
+    schema), the resulting print command must match what a fresh
+    `ReprintRequest()` / `FilePrintRequest()` would produce — anything
+    else means certain fields silently flip depending on which entry
+    point queued the job.
+
+    Earlier `vibration_cali` had a False default in the dispatch loop
+    against a True schema default, latent only because every existing
+    caller always sent the field.
+    """
+    import inspect
+
+    from backend.app.schemas.archive import ReprintRequest
+    from backend.app.schemas.library import FilePrintRequest
+    from backend.app.services import background_dispatch as bd
+
+    fields = ("bed_levelling", "flow_cali", "vibration_cali", "layer_inspect", "timelapse", "use_ams")
+    reprint_defaults = {f: getattr(ReprintRequest(), f) for f in fields}
+    libprint_defaults = {f: getattr(FilePrintRequest(), f) for f in fields}
+    assert reprint_defaults == libprint_defaults, (
+        "ReprintRequest and FilePrintRequest must share the same defaults for these fields"
+    )
+
+    src = inspect.getsource(bd)
+    for field, expected_default in reprint_defaults.items():
+        literal = "True" if expected_default else "False"
+        needle = f'{field}=job.options.get("{field}", {literal})'
+        count = src.count(needle)
+        assert count == 2, (
+            f"Expected exactly 2 occurrences of `{needle}` in background_dispatch (one per "
+            f"`_process_job` branch). Found {count}. A drift between the request schema's "
+            f"default for `{field}` and the dispatch loop's `.get()` default means callers "
+            f"that bypass the schema will get inconsistent behaviour."
+        )
+
+
 @pytest.mark.asyncio
 async def test_cancel_job_not_found_returns_false():
     """Cancelling a nonexistent job returns not_found."""

+ 128 - 0
backend/tests/unit/services/test_bambu_ftp.py

@@ -386,6 +386,134 @@ class TestUpload:
         assert result is False
         client.disconnect()
 
+    def test_upload_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server, tmp_path):
+        """Some P2S firmware revisions return 426 on voidresp() even when the
+        file landed fully (TLS data-channel close races the 226). #1417
+        follow-up — verify via SIZE: when server size matches, proceed with
+        a warning instead of failing the dispatch.
+
+        Pre-#1417 the catch raised unconditionally and the reporter saw 11
+        retries fail in a row even though every upload was actually
+        succeeding on the printer side (v0.2.4.1 worked because the prior
+        proceed-with-warning branch tolerated the noise).
+        """
+        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
+
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data" * 256)  # 1024 bytes
+        client = ftp_client_factory()
+        client.connect()
+
+        def raise_426():
+            raise ftplib.error_temp("426 Failure reading network stream.")
+
+        def fake_size(_path):
+            # Real P2S firmware: voidresp returns 426 but the file IS on
+            # the SD card at its full size. Mock can't reproduce both
+            # halves naturally because pyftpdlib only flushes on a clean
+            # voidresp, so we inject SIZE explicitly to model the
+            # printer-side state the user observes.
+            return 1024
+
+        client._ftp.voidresp = raise_426
+        client._ftp.size = fake_size
+
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is True, "intact file (SIZE match) tolerates 426 noise"
+        client.disconnect()
+
+    def test_upload_426_with_truncated_file_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
+        """The original #1401 fix is preserved: when SIZE confirms the file
+        isn't on the server at full size (or SIZE itself fails), the upload
+        must fail so the dispatcher doesn't send a print command for a
+        partial 3MF."""
+        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
+
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data" * 256)
+        client = ftp_client_factory()
+        client.connect()
+
+        def raise_426():
+            raise ftplib.error_temp("426 Failure reading network stream.")
+
+        # Make SIZE report a smaller value — file is genuinely truncated.
+        def fake_size(_path):
+            return 100
+
+        client._ftp.voidresp = raise_426
+        client._ftp.size = fake_size
+
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False, "truncated file (SIZE mismatch) must fail"
+        client.disconnect()
+
+    def test_upload_426_with_size_check_failing_returns_false(self, ftp_client_factory, ftp_server, tmp_path):
+        """If SIZE itself fails (e.g. server too broken to answer), assume
+        the worst and fail — better a retry than a print on a partial file.
+        """
+        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
+
+        local = tmp_path / "test.bin"
+        local.write_bytes(b"data" * 256)
+        client = ftp_client_factory()
+        client.connect()
+
+        def raise_426():
+            raise ftplib.error_temp("426 Failure reading network stream.")
+
+        def raise_size(_path):
+            raise ftplib.error_perm("550 File not found.")
+
+        client._ftp.voidresp = raise_426
+        client._ftp.size = raise_size
+
+        result = client.upload_file(local, "/cache/test.bin")
+        assert result is False
+        client.disconnect()
+
+    def test_upload_bytes_426_with_intact_file_proceeds(self, ftp_client_factory, ftp_server):
+        """upload_bytes() mirrors the same SIZE-verify logic as upload_file."""
+        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
+
+        client = ftp_client_factory()
+        client.connect()
+        data = b"x" * 1024
+
+        def raise_426():
+            raise ftplib.error_temp("426 Failure reading network stream.")
+
+        def fake_size(_path):
+            return 1024  # printer-side file matches expected size
+
+        client._ftp.voidresp = raise_426
+        client._ftp.size = fake_size
+
+        result = client.upload_bytes(data, "/cache/bytes.bin")
+        assert result is True
+        client.disconnect()
+
+    def test_upload_bytes_426_with_truncated_file_returns_false(self, ftp_client_factory, ftp_server):
+        """The truncated branch for upload_bytes()."""
+        import ftplib  # nosec B402 — tests need the real ftplib to construct mock 426 responses
+
+        client = ftp_client_factory()
+        client.connect()
+        data = b"x" * 1024
+
+        def raise_426():
+            raise ftplib.error_temp("426 Failure reading network stream.")
+
+        def fake_size(_path):
+            return 100
+
+        client._ftp.voidresp = raise_426
+        client._ftp.size = fake_size
+
+        result = client.upload_bytes(data, "/cache/bytes.bin")
+        assert result is False
+        client.disconnect()
+
     def test_upload_bytes_success(self, ftp_client_factory, ftp_server):
         """upload_bytes() writes data to server."""
         data = b"Bytes upload content"

+ 232 - 19
backend/tests/unit/services/test_bambu_mqtt.py

@@ -746,6 +746,66 @@ class TestAMSDataMerging:
         assert ams_data[0]["tray"][0]["tray_type"] == "PLA", "A1 should still have PLA"
         assert ams_data[1]["tray"][0]["tray_type"] == "PLA", "B1 should still have PLA"
 
+    def test_tray_exist_bits_promotes_empty_slot_to_state_9(self, mqtt_client):
+        """#1322 follow-up by @RosdasHH: the previous fix only caught the bare
+        {"id": N} payload firmware sends right after a printer restart. In
+        steady-state operation firmware sends a populated payload and signals
+        emptiness via tray_exist_bits — the canonical BambuStudio detection.
+        The bitmask handler now promotes empty slots to state=9 so the rest
+        of the app (API serializer, inventory short-circuit, AMS card) sees
+        one signal instead of guessing from payload shape.
+
+        State must be int 9, not "9" — `tray_state in {9, 10}` downstream
+        uses `==` comparison and would silently miss a string.
+        """
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "state": 11, "remain": 80},
+                        {"id": 1, "tray_type": "PETG", "tray_color": "00FF00", "state": 11, "remain": 60},
+                    ],
+                }
+            ],
+            "tray_exist_bits": "3",  # both slots occupied (0b11)
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+
+        # Slot 1 goes empty — populated payload, only the bitmask says so.
+        update_ams = {
+            "ams": [{"id": 0, "tray": [{"id": 0}, {"id": 1}]}],
+            "tray_exist_bits": "1",  # slot 1 now empty (0b01)
+        }
+        mqtt_client._handle_ams_data(update_ams)
+
+        slot1 = mqtt_client.state.raw_data["ams"][0]["tray"][1]
+        assert slot1["state"] == 9, "empty-by-bitmask slot must report state=9"
+        assert isinstance(slot1["state"], int), "state must be int for downstream == comparison"
+        # Loaded slot keeps its firmware state unchanged.
+        slot0 = mqtt_client.state.raw_data["ams"][0]["tray"][0]
+        assert slot0["state"] == 11, "loaded slot must keep its firmware state"
+
+    def test_tray_exist_bits_does_not_change_state_on_loaded_slots(self, mqtt_client):
+        """Belt and suspenders for the negative path: the new state=9
+        promotion must fire ONLY when the bitmask bit is 0. A loaded slot
+        with state=3 (or any other non-9 firmware value) must pass through
+        untouched, or we'd corrupt every printer that sends transitional
+        states like 'unloading'."""
+        initial_ams = {
+            "ams": [
+                {
+                    "id": 0,
+                    "tray": [
+                        {"id": 0, "tray_type": "PLA", "tray_color": "FF0000", "state": 3, "remain": 80},
+                    ],
+                }
+            ],
+            "tray_exist_bits": "1",  # slot occupied
+        }
+        mqtt_client._handle_ams_data(initial_ams)
+        assert mqtt_client.state.raw_data["ams"][0]["tray"][0]["state"] == 3
+
     def test_shutdown_message_preserves_ams_data(self, mqtt_client):
         """Printer shutdown (power_on_flag=False) must not wipe AMS slot data (#765).
 
@@ -3696,9 +3756,9 @@ class TestStartPrintAmsMapping:
         assert cmd["layer_inspect"] == 1
 
     def test_p2s_still_uses_boolean_format(self, mqtt_client):
-        """Regression guard: P2S is NOT in the is_h2d gate — must still use booleans.
+        """Regression guard: P2S is NOT in the H-family firmware gate — must still use booleans.
 
-        Adding X2D to the is_h2d set must not accidentally affect P2S, which
+        Adding X2D to the H-family set must not accidentally affect P2S, which
         is single-nozzle and uses boolean format like X1C/A1/P1.
         """
         mqtt_client.model = "P2S"
@@ -3708,6 +3768,58 @@ class TestStartPrintAmsMapping:
         assert cmd["timelapse"] is True
         assert cmd["flow_cali"] is False
 
+    def test_h2s_single_external_spool_uses_main_id(self, mqtt_client):
+        """H2S is single-nozzle (#1386): external spool (254) → ams_id=255.
+
+        H2S shares serial prefix "094" and the H-family firmware-format
+        quirks with H2D, but it has a single extruder (nozzle_count=1
+        confirmed across 9+ support bundles). Routing the deputy-nozzle
+        sentinel (254) through to firmware on a single-nozzle printer
+        causes 07FF_8012 "Failed to get AMS mapping table" — exactly the
+        symptom reporter krootstijn hit when printing without an AMS.
+        """
+        mqtt_client.model = "H2S"
+        mqtt_client.start_print("test.3mf", ams_mapping=[254])
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["ams_mapping"] == [-1]
+        assert cmd["ams_mapping2"] == [{"ams_id": 255, "slot_id": 0}]
+
+    def test_h2s_no_ams_forces_use_ams_false(self, mqtt_client):
+        """H2S with only external spool must drop into the use_ams=False
+        fallback, like P1S/P1P. The dual-nozzle bypass kept this path
+        unreachable before #1386 — the firmware then rejected the print
+        with 07FF_8012 because there was no AMS mapping table.
+        """
+        mqtt_client.model = "H2S"
+        mqtt_client.start_print("test.3mf", ams_mapping=[254], use_ams=True)
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["use_ams"] is False
+
+    def test_h2s_keeps_integer_format_for_calibration_fields(self, mqtt_client):
+        """H2S shares the H-family firmware (int 0/1 for calibration fields)
+        even though it's single-nozzle. Verified empirically against H2S
+        bundles: the print command structure was always accepted, only the
+        AMS routing failed (#1386).
+        """
+        mqtt_client.model = "H2S"
+        mqtt_client.start_print(
+            "test.3mf",
+            timelapse=True,
+            bed_levelling=False,
+            flow_cali=True,
+            vibration_cali=False,
+            layer_inspect=True,
+        )
+
+        cmd = self._get_published_command(mqtt_client)
+        assert cmd["timelapse"] == 1
+        assert cmd["bed_leveling"] == 0
+        assert cmd["flow_cali"] == 1
+        assert cmd["vibration_cali"] == 0
+        assert cmd["layer_inspect"] == 1
+
 
 class TestStartPrintUniqueIdentityFields:
     """Regression guard: project_id/subtask_id/task_id must be unique per submission (#1011).
@@ -3818,15 +3930,16 @@ class TestStartPrintUniqueIdentityFields:
 
 
 class TestDeleteKProfileDualNozzleDetection:
-    """Regression guard: dual-nozzle detection by serial prefix (#988).
+    """Regression guard: dual-nozzle detection for K-profile delete.
 
-    delete_kprofile branches on serial-prefix-derived dual-nozzle status.
-    H2D serials start with "094"; X2D serials start with "20P9". Non-dual
-    families (X1C "00M", P1S "01P", P2S "22E", A1 "039", etc.) must take
-    the single-nozzle branch.
+    delete_kprofile branches on dual-nozzle status to pick the wire format.
+    Source of truth is the runtime `_is_dual_nozzle` flag (set from
+    device.extruder.info); model name is the fallback used before push
+    data arrives. Serial-prefix detection alone is wrong — H2S shares
+    prefix "094" with H2D but is single-nozzle (#1386).
     """
 
-    def _make_client(self, serial: str):
+    def _make_client(self, *, serial: str = "TEST", model: str | None = None, dual_runtime: bool = False):
         from unittest.mock import MagicMock
 
         from backend.app.services.bambu_mqtt import BambuMQTTClient
@@ -3838,37 +3951,63 @@ class TestDeleteKProfileDualNozzleDetection:
         )
         client._client = MagicMock()
         client.state.connected = True
+        client.model = model
+        client._is_dual_nozzle = dual_runtime
         return client
 
     def _published(self, client):
         return json.loads(client._client.publish.call_args[0][1])["print"]
 
-    def test_h2d_serial_uses_dual_nozzle_format(self):
-        client = self._make_client("09400A000000001")
+    def test_h2d_model_uses_dual_nozzle_format(self):
+        client = self._make_client(serial="09400A000000001", model="H2D")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         cmd = self._published(client)
         # Dual-nozzle command omits setting_id.
         assert "setting_id" not in cmd
         assert cmd["extruder_id"] == 0
 
-    def test_x2d_serial_uses_dual_nozzle_format(self):
-        client = self._make_client("20P90A000000001")
+    def test_x2d_model_uses_dual_nozzle_format(self):
+        client = self._make_client(serial="20P90A000000001", model="X2D")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         cmd = self._published(client)
         assert "setting_id" not in cmd
         assert cmd["extruder_id"] == 0
 
-    def test_h2c_new_prefix_uses_dual_nozzle_format(self):
-        """Post-2026 H2C batches ship with '31B8B' prefix instead of '094' (#1105)."""
-        client = self._make_client("31B8BP000000001")
+    def test_h2c_model_uses_dual_nozzle_format(self):
+        """Post-2026 H2C batches ship with '31B8B' prefix instead of '094' (#1105).
+        Model-name detection works regardless of serial prefix."""
+        client = self._make_client(serial="31B8BP000000001", model="H2C")
         client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
         cmd = self._published(client)
         assert "setting_id" not in cmd
         assert cmd["extruder_id"] == 0
 
-    def test_p2s_serial_uses_single_nozzle_format(self):
+    def test_runtime_dual_nozzle_flag_uses_dual_format(self):
+        """When _is_dual_nozzle is set from device.extruder.info, the model
+        fallback isn't needed (covers future dual-nozzle models we haven't
+        seen yet)."""
+        client = self._make_client(serial="UNKNOWN", model=None, dual_runtime=True)
+        client.delete_kprofile(cali_idx=1, filament_id="GFA00", nozzle_id="HH00-0.4")
+        cmd = self._published(client)
+        assert "setting_id" not in cmd
+
+    def test_h2s_uses_single_nozzle_format(self):
+        """H2S shares serial prefix "094" with H2D but is single-nozzle (#1386).
+        Must take the single-nozzle branch with setting_id included.
+        """
+        client = self._make_client(serial="09400S000000001", model="H2S")
+        client.delete_kprofile(
+            cali_idx=1,
+            filament_id="GFA00",
+            nozzle_id="HH00-0.4",
+            setting_id="PFB123",
+        )
+        cmd = self._published(client)
+        assert cmd["setting_id"] == "PFB123"
+
+    def test_p2s_uses_single_nozzle_format(self):
         """P2S is single-nozzle — must NOT take the dual-nozzle branch."""
-        client = self._make_client("22E00A000000001")
+        client = self._make_client(serial="22E00A000000001", model="P2S")
         client.delete_kprofile(
             cali_idx=1,
             filament_id="GFA00",
@@ -3879,8 +4018,8 @@ class TestDeleteKProfileDualNozzleDetection:
         # Single-nozzle command includes setting_id.
         assert cmd["setting_id"] == "PFB123"
 
-    def test_x1c_serial_uses_single_nozzle_format(self):
-        client = self._make_client("00M00A000000001")
+    def test_x1c_uses_single_nozzle_format(self):
+        client = self._make_client(serial="00M00A000000001", model="X1C")
         client.delete_kprofile(
             cali_idx=1,
             filament_id="GFA00",
@@ -4919,3 +5058,77 @@ class TestAmsFilamentSettingExternalSpoolEncoding:
         # a future capture-driven change shows up in the diff.
         assert cmd["tray_id"] == 0
         assert cmd["slot_id"] == 0
+
+
+class TestDryingCompleteCallback:
+    """#1349 — fires ``on_drying_complete(ams_id)`` on a dry_time falling edge."""
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        events: list[int] = []
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST-DRYING",
+            access_code="12345678",
+            on_drying_complete=events.append,
+        )
+        client._drying_events = events  # Expose for assertions
+        return client
+
+    def test_falling_edge_fires_callback(self, mqtt_client):
+        """First push reports drying active, second reports drying done."""
+        # Push 1: AMS 0 drying with 60 minutes remaining.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 60, "tray": []}]})
+        assert mqtt_client._drying_events == []
+
+        # Push 2: dry_time hits 0 → callback fires with the AMS id.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+
+    def test_no_fire_when_dry_time_never_started(self, mqtt_client):
+        """dry_time = 0 across consecutive pushes does NOT fire — there was
+        no drying cycle to finish. Guards against the seed-from-zero false
+        positive on startup."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == []
+
+    def test_falling_edge_fires_once(self, mqtt_client):
+        """Subsequent zero-pushes after the edge don't refire the callback."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 30, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+
+    def test_per_ams_tracking(self, mqtt_client):
+        """Two AMS units finishing drying at different times each fire once
+        — the falling-edge state is keyed per AMS id."""
+        # Both start drying.
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 30, "tray": []}, {"id": "1", "dry_time": 30, "tray": []}]}
+        )
+        # AMS 0 finishes, AMS 1 still drying.
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 0, "tray": []}, {"id": "1", "dry_time": 15, "tray": []}]}
+        )
+        assert mqtt_client._drying_events == [0]
+        # AMS 1 finishes.
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 0, "tray": []}, {"id": "1", "dry_time": 0, "tray": []}]}
+        )
+        assert mqtt_client._drying_events == [0, 1]
+
+    def test_restart_drying_after_completion_refires_callback(self, mqtt_client):
+        """A new drying cycle after the previous one finished fires the
+        callback again on its own falling edge — covers the user manually
+        starting a second dry from the UI."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 30, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        # New cycle starts.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 45, "tray": []}]})
+        # And finishes.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0, 0]

+ 281 - 0
backend/tests/unit/services/test_camera_diagnose.py

@@ -0,0 +1,281 @@
+"""Unit tests for the staged camera diagnostic.
+
+Covers the per-stage pass/fail contract that drives the frontend
+remediation hints. The live-stream shortcut and the failure-to-summary
+mapping are the load-bearing pieces — both are pinned with explicit
+tests so future profile/protocol changes don't silently turn
+"camera_port_closed" into "printer_unreachable".
+"""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.camera_diagnose import (
+    _LIVE_FRAME_FRESHNESS_SECONDS,
+    diagnose_camera,
+)
+
+
+class TestLiveStreamShortcut:
+    """If a viewer is currently watching the camera with a fresh frame,
+    diagnose must NOT open a fresh socket — single-camera-connection
+    firmwares would kick the live viewer off. Trust the live evidence.
+    """
+
+    @pytest.mark.asyncio
+    async def test_skips_test_when_fresh_frame_in_active_stream(self):
+        result = await diagnose_camera(
+            ip_address="192.0.2.1",
+            access_code="x",
+            model="X1C",
+            printer_id=1,
+            has_live_stream=True,
+            live_frame_age_seconds=2.0,
+        )
+        assert result.overall_status == "ok"
+        assert result.summary_code == "live_stream_active_healthy"
+        assert len(result.stages) == 1
+        assert result.stages[0].name == "live_stream_active"
+        assert result.stages[0].status == "ok"
+
+    @pytest.mark.asyncio
+    async def test_runs_test_when_stale_frame_in_active_stream(self):
+        """An active stream with a stale buffered frame (e.g. mid-
+        reconnect) shouldn't short-circuit — the stream might be
+        wedged and the user needs the real test."""
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="X1C",
+                printer_id=1,
+                has_live_stream=True,
+                live_frame_age_seconds=_LIVE_FRAME_FRESHNESS_SECONDS + 5,
+            )
+        # No short-circuit — we ran the real check and it failed.
+        assert result.summary_code != "live_stream_active_healthy"
+        assert any(s.name == "tcp_reachable" for s in result.stages)
+
+
+class TestTcpStage:
+    """The first stage answers "can we even talk to the printer at all".
+    The three failure modes (timeout / refused / unreachable) map to
+    distinct user-facing remediation hints, so the codes must round-
+    trip correctly through ``_summary_for_stages``."""
+
+    @pytest.mark.asyncio
+    async def test_timeout_maps_to_printer_unreachable(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.99",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "failed"
+        assert result.summary_code == "printer_unreachable"
+        first = result.stages[0]
+        assert first.name == "tcp_reachable"
+        assert first.code == "tcp_timeout"
+        # Second stage was skipped — no point spawning ffmpeg with no socket.
+        assert result.stages[1].name == "first_frame"
+        assert result.stages[1].status == "skipped"
+
+    @pytest.mark.asyncio
+    async def test_connection_refused_maps_to_camera_port_closed(self):
+        """ConnectionRefusedError = printer up, port closed. Common
+        cause: LAN-only mode off, or developer mode off. The user
+        sees a specific remediation hint, not the generic
+        'unreachable' message."""
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=ConnectionRefusedError(),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "camera_port_closed"
+        assert result.stages[0].code == "tcp_refused"
+
+    @pytest.mark.asyncio
+    async def test_oserror_maps_to_printer_unreachable(self):
+        """Generic OSError (no-route-to-host etc.) lumps under
+        'printer_unreachable' — same remediation as timeout."""
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=OSError("No route to host"),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "printer_unreachable"
+        assert result.stages[0].code == "tcp_unreachable"
+
+
+class TestFirstFrameStage:
+    """The second stage answers "is the camera actually producing
+    frames". If TCP passes but no frame comes back, the answer is the
+    same regardless of which sub-layer failed (auth, RTSP handshake,
+    keyframe probe): the user can't see the camera."""
+
+    @pytest.mark.asyncio
+    async def test_no_frame_maps_to_no_frame_summary(self):
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "failed"
+        assert result.summary_code == "no_frame"
+        assert result.stages[0].status == "ok"
+        assert result.stages[1].name == "first_frame"
+        assert result.stages[1].code == "no_frame"
+
+    @pytest.mark.asyncio
+    async def test_capture_exception_maps_to_no_frame_summary(self):
+        """ffmpeg crash / TLS proxy startup failure / etc. — all the
+        sub-layer exceptions surface as 'no_frame' for the user, with
+        a distinct ``capture_exception`` code in the stage so the
+        support log retains the distinction."""
+
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                side_effect=RuntimeError("ffmpeg died"),
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.summary_code == "no_frame"
+        assert result.stages[1].code == "capture_exception"
+
+    @pytest.mark.asyncio
+    async def test_full_success_path(self):
+        async def _tcp_ok(*_a, **_kw):
+            writer = AsyncMock()
+            return AsyncMock(), writer
+
+        with (
+            patch(
+                "backend.app.services.camera_diagnose.asyncio.open_connection",
+                new=_tcp_ok,
+            ),
+            patch(
+                "backend.app.services.camera_diagnose.capture_camera_frame_bytes",
+                new_callable=AsyncMock,
+                return_value=b"\xff\xd8\xff\xd9",  # tiny valid-looking JPEG
+            ),
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.overall_status == "ok"
+        assert result.summary_code == "all_ok"
+        assert all(s.status == "ok" for s in result.stages)
+
+
+class TestResultMetadata:
+    """Surface fields the support triage relies on — protocol, port,
+    profile name. The frontend renders these so we can ask the user
+    'is your profile 'P2S' or 'default'?' over a screenshot rather
+    than asking for the support bundle."""
+
+    @pytest.mark.asyncio
+    async def test_p2s_reports_p2s_profile_and_rtsp_protocol(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="P2S",
+                printer_id=1,
+            )
+        assert result.protocol == "rtsp"
+        assert result.profile == "P2S"
+        assert result.port == 322
+
+    @pytest.mark.asyncio
+    async def test_a1_reports_default_profile_and_chamber_protocol(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="A1",
+                printer_id=1,
+            )
+        assert result.protocol == "chamber_image"
+        assert result.profile == "default"
+        assert result.port == 6000
+
+    @pytest.mark.asyncio
+    async def test_x1c_reports_default_profile_and_rtsp(self):
+        with patch(
+            "backend.app.services.camera_diagnose.asyncio.open_connection",
+            new_callable=AsyncMock,
+            side_effect=TimeoutError,
+        ):
+            result = await diagnose_camera(
+                ip_address="192.0.2.1",
+                access_code="x",
+                model="X1C",
+                printer_id=1,
+            )
+        assert result.protocol == "rtsp"
+        assert result.profile == "default"
+        assert result.port == 322

+ 90 - 0
backend/tests/unit/services/test_camera_profiles.py

@@ -0,0 +1,90 @@
+"""Unit tests for camera profile registry.
+
+The registry decouples per-model camera tuning (probesize, analyzeduration,
+reconnect cadence) from the hard-coded constants that lived in
+``camera.py`` until #1395 follow-up. Adding a new model's quirk should
+be a config edit, not a code change.
+"""
+
+from dataclasses import FrozenInstanceError
+
+import pytest
+
+from backend.app.services.camera_profiles import (
+    DEFAULT_PROFILE,
+    CameraProfile,
+    get_camera_profile,
+)
+
+
+class TestGetCameraProfile:
+    def test_unknown_model_returns_default(self):
+        """Models with no override fall through to DEFAULT_PROFILE so the
+        camera path is never blocked on a missing entry."""
+        assert get_camera_profile("UNKNOWN_MODEL") is DEFAULT_PROFILE
+        assert get_camera_profile("Future_Bambu_Model_X42") is DEFAULT_PROFILE
+
+    def test_none_model_returns_default(self):
+        """`None` / empty model (very early in connect handshake) must not
+        crash; the default profile is safe for any RTSP-capable printer."""
+        assert get_camera_profile(None) is DEFAULT_PROFILE
+        assert get_camera_profile("") is DEFAULT_PROFILE
+
+    def test_default_profile_preserves_historical_fast_startup(self):
+        """X1/H2 fast-startup tuning is the historical baseline. The first
+        refactor must not regress it for the printers that already worked.
+        """
+        assert DEFAULT_PROFILE.probesize == 32
+        assert DEFAULT_PROFILE.analyzeduration == 0
+        assert DEFAULT_PROFILE.rtsp_reconnect_max == 30
+        assert DEFAULT_PROFILE.rtsp_reconnect_delay == 0.2
+
+    def test_p2s_has_relaxed_probe(self):
+        """P2S firmware 01.02.00.00 needs more probe room — ffmpeg's own
+        diagnostic says so. This is the first per-model override and the
+        regression to guard."""
+        profile = get_camera_profile("P2S")
+        assert profile is not DEFAULT_PROFILE
+        # Order of magnitude up from the default — enough to lock onto a
+        # slow-keyframe stream without adding multi-second startup.
+        assert profile.probesize >= 1_000_000
+        assert profile.analyzeduration >= 500_000
+
+    def test_p2s_internal_code_resolves_to_p2s_profile(self):
+        """SSDP internal codes (e.g. `N7` for P2S) must resolve to the
+        same profile as their display name. Otherwise printers freshly
+        connected (before display-name lookup completes) would use the
+        default profile and hit the same #1395 bug."""
+        assert get_camera_profile("N7") is get_camera_profile("P2S")
+
+    def test_lookup_is_case_insensitive(self):
+        """Display-name capitalisation should not matter — callers may
+        carry lowercase or mixed-case values straight from MQTT."""
+        assert get_camera_profile("p2s") is get_camera_profile("P2S")
+        assert get_camera_profile("P2s") is get_camera_profile("P2S")
+
+    def test_known_rtsp_models_keep_default_unchanged(self):
+        """X1, X1C, X1E, H2D, H2S, X2D — every other RTSP-capable model
+        must use the default profile until proven otherwise. Anything
+        else means we silently changed behaviour for a model the user
+        hasn't reported a problem on."""
+        for model in ("X1", "X1C", "X1E", "X2D", "H2C", "H2D", "H2D PRO", "H2S"):
+            assert get_camera_profile(model) is DEFAULT_PROFILE, (
+                f"{model} unexpectedly has a non-default profile — review "
+                "whether the change is intentional before shipping."
+            )
+
+
+class TestCameraProfileShape:
+    def test_profile_is_frozen(self):
+        """Profiles are immutable; mutating them at runtime would
+        introduce action-at-a-distance for the camera generator."""
+        with pytest.raises(FrozenInstanceError):
+            DEFAULT_PROFILE.probesize = 999  # type: ignore[misc]
+
+    def test_extra_ffmpeg_input_args_defaults_to_empty_tuple(self):
+        """Profiles can declare extra `-flag value` pairs to splice into
+        the ffmpeg input args without changing the dataclass shape.
+        Default is empty so the historical command is unchanged."""
+        p = CameraProfile()
+        assert p.extra_ffmpeg_input_args == ()

+ 148 - 0
backend/tests/unit/services/test_homeassistant_list_entities.py

@@ -0,0 +1,148 @@
+"""Regression tests for HomeAssistantService.list_entities domain filtering (#1388).
+
+Reporter MartinNYHC opened the Add Smart Plug modal in HA mode, typed a search
+matching a multi-entity device (one Shelly outlet exposed as switch + several
+sensor.* and binary_sensor.* siblings), and clicked a non-switch entity. The
+schema regex for ha_entity_id only accepts switch/light/input_boolean/script,
+so the Save round-trip came back 422 with the raw Pydantic pattern string —
+the same regex shown in the bug report screenshot.
+
+Root cause: before this fix, the search path bypassed the domain filter
+entirely, so the dropdown showed every entity whose entity_id or friendly_name
+matched the query, regardless of whether the schema would later accept it.
+Users could click an entity they had no way to actually save.
+
+Fix: always apply the allowed-domains filter, and apply the search filter on
+top of it. The two filters now compose instead of branching.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.homeassistant import HomeAssistantService
+
+
+def _ha_response(entities: list[dict]) -> MagicMock:
+    response = MagicMock()
+    response.raise_for_status = MagicMock()
+    response.json = MagicMock(return_value=entities)
+    return response
+
+
+def _mock_get(entities: list[dict]):
+    async_client = MagicMock()
+    async_client.get = AsyncMock(return_value=_ha_response(entities))
+    async_client.__aenter__ = AsyncMock(return_value=async_client)
+    async_client.__aexit__ = AsyncMock(return_value=None)
+    return async_client
+
+
+@pytest.mark.asyncio
+async def test_no_search_returns_only_allowed_domains():
+    """Without a search query, only switch/light/input_boolean/script appear."""
+    entities = [
+        {"entity_id": "switch.printer", "attributes": {"friendly_name": "Printer"}, "state": "on"},
+        {"entity_id": "light.lamp", "attributes": {"friendly_name": "Lamp"}, "state": "off"},
+        {"entity_id": "input_boolean.flag", "attributes": {"friendly_name": "Flag"}, "state": "on"},
+        {"entity_id": "script.morning", "attributes": {"friendly_name": "Morning"}, "state": "off"},
+        {"entity_id": "sensor.power", "attributes": {"friendly_name": "Power"}, "state": "12.3"},
+        {"entity_id": "binary_sensor.status", "attributes": {"friendly_name": "Status"}, "state": "on"},
+        {"entity_id": "media_player.tv", "attributes": {"friendly_name": "TV"}, "state": "idle"},
+    ]
+    service = HomeAssistantService()
+
+    with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
+        result = await service.list_entities("http://ha.local", "tok")
+
+    domains = sorted({e["domain"] for e in result})
+    assert domains == ["input_boolean", "light", "script", "switch"]
+    assert len(result) == 4
+
+
+@pytest.mark.asyncio
+async def test_search_still_filters_to_allowed_domains():
+    """#1388: search must compose with the domain filter, not replace it.
+
+    Reporter's setup: a Shelly outlet device generates one switch.* entity
+    and several sensor.*/binary_sensor.* siblings, all sharing a common
+    friendly-name prefix. The user searched the prefix and was offered the
+    non-switch siblings as clickable options — picking one led to the 422
+    pattern error. After the fix, the search-narrowed list excludes them.
+    """
+    entities = [
+        {
+            "entity_id": "switch.prise_imprimante_3d_bambu_output_1",
+            "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1"},
+            "state": "on",
+        },
+        {
+            "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_power",
+            "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Puissance"},
+            "state": "12.5",
+        },
+        {
+            "entity_id": "binary_sensor.prise_imprimante_3d_bambu_output_1_status",
+            "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Status"},
+            "state": "on",
+        },
+        {
+            "entity_id": "sensor.prise_imprimante_3d_bambu_output_1_energy",
+            "attributes": {"friendly_name": "Prise imprimante 3D Bambu Output 1 Énergie"},
+            "state": "0.42",
+        },
+    ]
+    service = HomeAssistantService()
+
+    with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
+        result = await service.list_entities("http://ha.local", "tok", search="Prise imprimante")
+
+    assert len(result) == 1
+    assert result[0]["entity_id"] == "switch.prise_imprimante_3d_bambu_output_1"
+
+
+@pytest.mark.asyncio
+async def test_search_matches_by_entity_id_or_friendly_name():
+    """Search still matches across both fields, just within the allowed set."""
+    entities = [
+        {"entity_id": "switch.printer_a", "attributes": {"friendly_name": "Living Room Plug"}, "state": "on"},
+        {"entity_id": "switch.printer_b", "attributes": {"friendly_name": "Office Plug"}, "state": "off"},
+        {"entity_id": "light.living_room", "attributes": {"friendly_name": "Ceiling"}, "state": "off"},
+    ]
+    service = HomeAssistantService()
+
+    with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
+        result = await service.list_entities("http://ha.local", "tok", search="living")
+
+    ids = sorted(e["entity_id"] for e in result)
+    assert ids == ["light.living_room", "switch.printer_a"]
+
+
+@pytest.mark.asyncio
+async def test_search_is_case_insensitive():
+    entities = [
+        {"entity_id": "switch.PRINTER", "attributes": {"friendly_name": "Printer"}, "state": "on"},
+    ]
+    service = HomeAssistantService()
+
+    with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
+        result = await service.list_entities("http://ha.local", "tok", search="PRINTER")
+
+    assert len(result) == 1
+
+
+@pytest.mark.asyncio
+async def test_empty_search_treated_as_no_search():
+    """A whitespace-only search string should fall back to the full allowed-
+    domain list rather than matching everything that contains an empty string."""
+    entities = [
+        {"entity_id": "switch.foo", "attributes": {"friendly_name": "Foo"}, "state": "on"},
+        {"entity_id": "sensor.bar", "attributes": {"friendly_name": "Bar"}, "state": "1"},
+    ]
+    service = HomeAssistantService()
+
+    with patch("httpx.AsyncClient", return_value=_mock_get(entities)):
+        result = await service.list_entities("http://ha.local", "tok", search="   ")
+
+    assert len(result) == 1
+    assert result[0]["entity_id"] == "switch.foo"

+ 17 - 9
backend/tests/unit/services/test_label_renderer.py

@@ -6,7 +6,14 @@ import pytest
 
 from backend.app.services.label_renderer import LabelData, render_labels
 
-ALL_TEMPLATES = ("ams_30x15", "box_40x30", "box_62x29", "avery_5160", "avery_l7160")
+ALL_TEMPLATES = (
+    "ams_holder_74x33",
+    "ams_holder_75x55",
+    "box_40x30",
+    "box_62x29",
+    "avery_5160",
+    "avery_l7160",
+)
 
 
 def _sample(spool_id: int = 1, **overrides) -> LabelData:
@@ -58,7 +65,7 @@ def test_missing_optional_fields_does_not_crash():
             deeplink_url="https://example.test/inventory?spool=42",
         )
     ]
-    pdf = render_labels("ams_30x15", data)
+    pdf = render_labels("ams_holder_74x33", data)
     assert pdf.startswith(b"%PDF")
 
 
@@ -74,7 +81,7 @@ def test_long_strings_are_truncated_not_overflowed():
     long_brand = "A" * 200
     long_name = "B" * 300
     data = [_sample(brand=long_brand, name=long_name)]
-    pdf = render_labels("ams_30x15", data)
+    pdf = render_labels("ams_holder_74x33", data)
     assert pdf.startswith(b"%PDF")
 
 
@@ -121,9 +128,10 @@ def _render_uncompressed(template, data):
     from backend.app.services.label_renderer import _draw_label  # noqa: PLC0415
 
     # Mirror the page-size choice from render_labels but force pageCompression=0.
-    if template in ("ams_30x15", "box_40x30", "box_62x29"):
+    if template in ("ams_holder_74x33", "ams_holder_75x55", "box_40x30", "box_62x29"):
         sizes = {
-            "ams_30x15": (30.0, 15.0),
+            "ams_holder_74x33": (74.0, 33.0),
+            "ams_holder_75x55": (75.0, 55.0),
             "box_40x30": (40.0, 30.0),
             "box_62x29": (62.0, 29.0),
         }
@@ -167,9 +175,9 @@ def _render_uncompressed(template, data):
 def test_ams_template_actually_renders_text():
     """Regression: the first cut of the AMS-holder layout produced labels with
     only swatch + QR and no text at all because the side-by-side layout left
-    <5 mm for the text column. The redesign drops the QR on this template and
-    gives the right side to brand + material + spool ID. This pins that the
-    rendered PDF contains all three fields.
+    <5 mm for the text column. The current AMS templates use the roomy layout
+    (swatch + QR + multi-line text); this pins that the rendered PDF contains
+    brand + material + spool ID for the smaller AMS preset.
     """
     data = [
         LabelData(
@@ -182,7 +190,7 @@ def test_ams_template_actually_renders_text():
             deeplink_url="https://example.test/inventory?spool=42",
         )
     ]
-    pdf = _render_uncompressed("ams_30x15", data)
+    pdf = _render_uncompressed("ams_holder_74x33", data)
     assert b"Polymaker" in pdf, "AMS template must render the brand"
     assert b"PLA" in pdf, "AMS template must render the material"
     # The bracketed-hash style is what the renderer uses for the spool ID;

+ 95 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -40,6 +40,13 @@ class TestSmartPlugManager:
         plug.auto_off_pending = False
         plug.last_state = "ON"
         plug.last_checked = None
+        # #1349: drying defaults match the new schema — both off until the
+        # user opts in, so existing tests don't accidentally activate the
+        # post-drying path.
+        plug.plug_type = "tasmota"
+        plug.ha_entity_id = None
+        plug.auto_off_after_drying = False
+        plug.off_delay_after_drying_minutes = 10
         return plug
 
     @pytest.fixture
@@ -248,6 +255,94 @@ class TestSmartPlugManager:
 
             mock_schedule.assert_not_called()
 
+    # ========================================================================
+    # Tests for on_drying_complete (#1349)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_schedules_delayed_off_when_enabled(self, manager, mock_plug, mock_db):
+        """Plug with ``auto_off_after_drying=True`` gets a delayed-off scheduled
+        using its drying-specific delay (independent of print-finish delay)."""
+        mock_plug.auto_off_after_drying = True
+        mock_plug.off_delay_after_drying_minutes = 15
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_called_once_with(mock_plug, 1, 15 * 60)
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_skipped_when_toggle_off(self, manager, mock_plug, mock_db):
+        """Default state — toggle off → nothing scheduled. This is the regression
+        guard for users who only enable the print-finish auto-off and don't
+        want the AMS-drying path silently running on the same plug."""
+        mock_plug.auto_off_after_drying = False
+        # auto_off itself is True (existing print-finish behaviour) — the
+        # drying path must still be a no-op without its own toggle.
+        mock_plug.auto_off = True
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_skipped_when_plug_disabled(self, manager, mock_plug, mock_db):
+        """Drying auto-off honours the master ``enabled`` flag."""
+        mock_plug.auto_off_after_drying = True
+        mock_plug.enabled = False
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_skipped_for_ha_script_entity(self, manager, mock_plug, mock_db):
+        """HA script entities can be triggered but not turned off — same
+        guard the print-finish path has."""
+        mock_plug.auto_off_after_drying = True
+        mock_plug.plug_type = "homeassistant"
+        mock_plug.ha_entity_id = "script.lights_off"
+
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = [mock_plug]
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_drying_complete_no_op_when_no_plugs(self, manager, mock_db):
+        """Printer without any linked plugs is a silent no-op (not an error)."""
+        with (
+            patch.object(manager, "_get_plugs_for_printer", new_callable=AsyncMock) as mock_get_plug,
+            patch.object(manager, "_schedule_delayed_off") as mock_schedule,
+        ):
+            mock_get_plug.return_value = []
+
+            await manager.on_drying_complete(printer_id=1, db=mock_db)
+
+            mock_schedule.assert_not_called()
+
     # ========================================================================
     # Tests for _cancel_pending_off
     # ========================================================================

+ 153 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -611,6 +611,159 @@ class TestVirtualPrinterInstance:
         assert queue_item.layer_inspect is False
         assert queue_item.timelapse is False
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path):
+        """#1403: VP-queue items used to fall back to `default_timelapse` even
+        though the slicer's MQTT `project_file` command carries the user's
+        actual choice. Capture-via-`on_print_command` flow lets the user's
+        slicer toggle reach the queue item.
+
+        Settings here have timelapse OFF; the slicer's MQTT capture has it ON.
+        After the fix the queue item must reflect the slicer's choice.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=24,
+            name="SlicerInherits",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800024",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        # Pre-populate the capture as if MQTT `project_file` arrived already.
+        # Settings (below) deliberately have timelapse OFF — only the slicer
+        # capture should drive the resulting queue item.
+        await inst.on_print_command(
+            file_path.name,
+            {
+                "command": "project_file",
+                "timelapse": True,
+                "bed_leveling": False,  # Note: MQTT field is single-L `bed_leveling`
+                "flow_cali": True,
+                "vibration_cali": False,
+                "layer_inspect": True,
+            },
+        )
+
+        settings_map = {
+            "virtual_printer_archive_name_source": None,
+            "default_bed_levelling": "true",
+            "default_flow_cali": "false",
+            "default_vibration_cali": "true",
+            "default_layer_inspect": "false",
+            "default_timelapse": "false",
+        }
+
+        async def fake_get_setting(_db, key):
+            return settings_map.get(key)
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new=fake_get_setting,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
+        assert queue_item.bed_levelling is False, "Slicer's bed_leveling=False must override default_bed_levelling=True"
+        assert queue_item.flow_cali is True
+        assert queue_item.vibration_cali is False
+        assert queue_item.layer_inspect is True
+        # Capture is consumed — no lingering state for the next print of the same name.
+        assert file_path.name not in inst._slicer_print_options
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path):
+        """#1403: H-family firmwares carry calibration flags as integers
+        (0/1) rather than booleans. The capture must coerce both shapes so
+        H-family-sliced jobs through the VP queue work the same as P1/X1.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        added_items = []
+        mock_db = AsyncMock()
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=25,
+            name="SlicerIntegers",
+            mode="print_queue",
+            model="C12",
+            access_code="12345678",
+            serial_suffix="391800025",
+            auto_dispatch=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        await inst.on_print_command(
+            file_path.name,
+            {"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1},
+        )
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        queue_item = added_items[0]
+        assert queue_item.timelapse is True, "integer 1 must coerce to True"
+        assert queue_item.bed_levelling is False, "integer 0 must coerce to False"
+        assert queue_item.flow_cali is True
+
     @pytest.mark.asyncio
     async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
         """#1188: VP queue-mode used to create PrintQueueItems with no

+ 247 - 0
backend/tests/unit/test_print_log_backfill_migration.py

@@ -0,0 +1,247 @@
+"""Regression test for the PrintLogEntry → PrintArchive backfill migration (#1390).
+
+Reporter IndividualGhost1905 upgraded to 0.2.4.1 (which shipped the per-event
+aggregation rewrite from #1378) and saw Quick Stats partially break on old
+data:
+
+  - Total Filament Cost = 0 (PrintLogEntry.cost was NULL on pre-upgrade rows)
+  - Time Accuracy empty for pre-upgrade runs (the new query JOINs on
+    archive_id, which the column-add migration left NULL)
+
+#1378's migration added the columns but didn't backfill anything. This test
+pins the backfill that the same `run_migrations` pass now performs:
+
+  Step 1: link old log entries to their archive via print_name + printer_id.
+  Step 2: copy archive.cost / energy_kwh / energy_cost onto the latest
+          matching log entry per archive (so the sum across archives
+          reproduces the pre-fix total exactly — pre-#1378, archive.cost
+          held the LATEST run's value because reprints overwrote it).
+
+Earlier reprints stay with cost = NULL — matching #1378's "first/latest run
+writes, the rest stay NULL" convention for new prints, so reruns don't
+double-count.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch in run_migrations regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    """Import every model so Base.metadata knows the full schema."""
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine_with_legacy_data():
+    """Fresh schema + a legacy-shape dataset: two archives, four PrintLogEntry
+    rows. The cube.3mf archive carries cost+energy (the user's reprinted file);
+    gear.3mf has neither set. Three matching log entries simulate cube's
+    reprint history (status: failed → completed → completed). All log entries
+    start with archive_id and cost = NULL, exactly like the column-add
+    migration leaves on a pre-#1378 install."""
+    from sqlalchemy.ext.asyncio import async_sessionmaker
+
+    from backend.app.core.database import Base
+    from backend.app.models.archive import PrintArchive
+
+    _register_all_models()
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+
+    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
+    async with SessionLocal() as session:
+        session.add(
+            PrintArchive(
+                id=1,
+                filename="cube.3mf",
+                file_path="/x/cube.3mf",
+                file_size=100,
+                print_name="cube.3mf",
+                printer_id=1,
+                cost=4.25,
+                energy_kwh=0.42,
+                energy_cost=0.063,
+                status="completed",
+            )
+        )
+        session.add(
+            PrintArchive(
+                id=2,
+                filename="gear.3mf",
+                file_path="/x/gear.3mf",
+                file_size=100,
+                print_name="gear.3mf",
+                printer_id=1,
+                status="completed",
+            )
+        )
+        await session.commit()
+
+    async with engine.begin() as conn:
+        # Three log entries for cube.3mf (two early reprints + a latest run),
+        # one for gear.3mf. All with archive_id and cost NULL — exactly the
+        # state the column-add migration leaves on pre-#1378 installs.
+        base = datetime.now(timezone.utc) - timedelta(days=10)
+        for i, (delta_days, status, print_name) in enumerate(
+            [
+                (0, "failed", "cube.3mf"),
+                (1, "completed", "cube.3mf"),
+                (2, "completed", "cube.3mf"),  # latest run for cube — must receive backfill
+                (3, "completed", "gear.3mf"),
+            ],
+            start=1,
+        ):
+            ts = (base + timedelta(days=delta_days)).isoformat()
+            await conn.execute(
+                text("""
+                    INSERT INTO print_log_entries
+                        (id, print_name, printer_id, status, started_at, completed_at,
+                         duration_seconds, filament_used_grams, created_at)
+                    VALUES (:id, :pn, 1, :status, :ts, :ts, 3600, 25.0, :ts)
+                """),
+                {"id": i, "pn": print_name, "status": status, "ts": ts},
+            )
+
+        # Force NULL on the columns we want the migration to touch — the
+        # CREATE TABLE from Base.metadata.create_all already left them NULL,
+        # but we set explicitly so the fixture's intent is loud.
+        await conn.execute(
+            text("UPDATE print_log_entries SET archive_id = NULL, cost = NULL, energy_kwh = NULL, energy_cost = NULL")
+        )
+
+    yield engine
+    await engine.dispose()
+
+
+async def test_backfill_links_log_entries_to_their_archive(engine_with_legacy_data):
+    """All four entries should pick up archive_id after the migration runs."""
+    async with engine_with_legacy_data.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_legacy_data.connect() as conn:
+        result = await conn.execute(text("SELECT id, print_name, archive_id FROM print_log_entries ORDER BY id"))
+        rows = result.all()
+
+    assert rows == [
+        (1, "cube.3mf", 1),
+        (2, "cube.3mf", 1),
+        (3, "cube.3mf", 1),
+        (4, "gear.3mf", 2),
+    ]
+
+
+async def test_backfill_copies_cost_and_energy_to_latest_run_only(engine_with_legacy_data):
+    """Pre-#1378 archive.cost = LAST run's value because reprints overwrote it.
+    The backfill attributes that cost to the latest matching log entry; earlier
+    runs stay NULL so summing across runs reproduces sum-of-archive-costs
+    exactly — what the user saw before the upgrade."""
+    async with engine_with_legacy_data.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_legacy_data.connect() as conn:
+        result = await conn.execute(text("SELECT id, cost, energy_kwh, energy_cost FROM print_log_entries ORDER BY id"))
+        rows = result.all()
+
+    # Two earlier cube runs (id 1, 2): cost stays NULL.
+    assert rows[0] == (1, None, None, None)
+    assert rows[1] == (2, None, None, None)
+    # Latest cube run (id 3): receives archive 1's cost / energy.
+    assert rows[2] == (3, 4.25, 0.42, 0.063)
+    # gear run (id 4): archive 2 has no cost/energy so log stays NULL too.
+    assert rows[3] == (4, None, None, None)
+
+
+async def test_backfill_is_idempotent(engine_with_legacy_data):
+    """Running the migration twice produces the same state — no double-backfill,
+    no values pulled off rows the second pass would mistakenly treat as 'new'."""
+    async with engine_with_legacy_data.begin() as conn:
+        await run_migrations(conn)
+    async with engine_with_legacy_data.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine_with_legacy_data.connect() as conn:
+        result = await conn.execute(text("SELECT id, archive_id, cost FROM print_log_entries ORDER BY id"))
+        rows = result.all()
+
+    assert rows == [
+        (1, 1, None),
+        (2, 1, None),
+        (3, 1, 4.25),
+        (4, 2, None),
+    ]
+
+
+async def test_backfill_skips_archives_with_any_costed_run(engine_with_legacy_data):
+    """If ANY log entry for an archive already has cost set — e.g. the post-#1378
+    live write path filled it for a new run — the backfill leaves the entire
+    archive alone. This is the migration's idempotency anchor: 'cost is
+    accounted for somewhere on this archive's history' is the signal we use
+    to decide whether to inject the archive-level value. Backfilling another
+    row would double-count once the live writes start adding up."""
+    async with engine_with_legacy_data.begin() as conn:
+        # Pretend run #1 was written post-fix with its own cost.
+        await conn.execute(text("UPDATE print_log_entries SET cost = 1.11 WHERE id = 1"))
+        await run_migrations(conn)
+
+    async with engine_with_legacy_data.connect() as conn:
+        result = await conn.execute(text("SELECT id, cost FROM print_log_entries ORDER BY id"))
+        rows = result.all()
+
+    # Run #1 keeps its live-written cost. The archive already has a costed
+    # run, so the migration does NOT inject archive.cost onto run #3.
+    # gear.3mf (archive 2) still has nothing — but archive.cost is NULL
+    # there too, so the backfill UPDATE would set NULL → NULL anyway, which
+    # is the desired no-op.
+    assert dict(rows) == {1: 1.11, 2: None, 3: None, 4: None}

+ 207 - 0
backend/tests/unit/test_print_start_assigns_printer_id_to_vp_archive.py

@@ -0,0 +1,207 @@
+"""Regression for #1403 follow-up: when on_print_start reuses a VP-queue archive,
+it must assign archive.printer_id so the post-print "Scan for timelapse" path
+in the archive UI isn't disabled forever.
+
+Reporter @pwostran and @enjoylifenow both saw "Scan for timelapse" greyed out on
+archives that came from the VP print-queue flow even though the H.264 timelapse
+was sitting on the printer's SD card. The frontend gates that button on
+``!archive.printer_id`` (ArchivesPage.tsx:459). VP-queue archives are created
+with ``printer_id=None`` at queue-add time because we don't know which printer
+will run the job yet; the print-start handler's expected-archive branch updated
+status / started_at / subtask_id but never set printer_id, so the archive stayed
+unassigned forever.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.main import (
+    _active_prints,
+    _expected_print_creators,
+    _expected_print_registered_at,
+    _expected_prints,
+    _print_ams_mappings,
+    register_expected_print,
+)
+
+
+@pytest.fixture(autouse=True)
+def _clear_dicts():
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _expected_print_creators.clear()
+    _print_ams_mappings.clear()
+    _active_prints.clear()
+    yield
+    _expected_prints.clear()
+    _expected_print_registered_at.clear()
+    _expected_print_creators.clear()
+    _print_ams_mappings.clear()
+    _active_prints.clear()
+
+
+@pytest.mark.asyncio
+async def test_expected_archive_path_assigns_printer_id_when_unset():
+    """VP-queue archives land here with printer_id=None and must be promoted
+    to the printer that actually started the job. Without this the
+    /archives/{id}/timelapse/scan endpoint refuses the request (it requires
+    archive.printer_id) and the UI button stays disabled."""
+    mock_printer = MagicMock()
+    mock_printer.id = 1
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP1S"
+
+    # VP-queue archive: printer_id is None — this is the bug surface.
+    mock_archive = MagicMock()
+    mock_archive.id = 42
+    mock_archive.filename = "bambu_lab_a1_tool_plate_3.gcode.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = None
+    mock_archive.print_name = "A1 Tool Plate 3"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "/tmp/fake.3mf"  # nosec B108 — mock path; nothing ever writes to it
+    mock_archive.energy_start_kwh = None
+
+    register_expected_print(1, "bambu_lab_a1_tool_plate_3.gcode.3mf", archive_id=42, ams_mapping=None)
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+
+    with (
+        patch("backend.app.main.async_session") as mock_session_maker,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(
+            1,
+            {
+                "filename": "bambu_lab_a1_tool_plate_3.gcode.3mf",
+                "subtask_name": "bambu_lab_a1_tool_plate_3",
+            },
+        )
+
+        assert mock_archive.printer_id == 1, (
+            "expected-archive branch must assign the running printer_id so the "
+            "post-print timelapse-scan path (gated on archive.printer_id) works"
+        )
+        assert mock_archive.status == "printing"
+
+
+@pytest.mark.asyncio
+async def test_expected_archive_path_preserves_existing_printer_id():
+    """Defensive: if the archive already carries a printer_id (e.g. a
+    library-file-based queue item created with the printer pre-assigned),
+    don't clobber it with a stale value. The branch is idempotent on
+    correct data."""
+    mock_printer = MagicMock()
+    mock_printer.id = 7
+    mock_printer.auto_archive = True
+    mock_printer.external_camera_enabled = False
+    mock_printer.external_camera_url = None
+    mock_printer.name = "TestP1S"
+
+    mock_archive = MagicMock()
+    mock_archive.id = 99
+    mock_archive.filename = "MyModel.3mf"
+    mock_archive.subtask_id = None
+    mock_archive.print_time_seconds = None
+    mock_archive.created_by_id = None
+    mock_archive.printer_id = 7  # already correct
+    mock_archive.print_name = "MyModel"
+    mock_archive.status = "archived"
+    mock_archive.file_path = "/tmp/fake.3mf"  # nosec B108 — mock path; nothing ever writes to it
+    mock_archive.energy_start_kwh = None
+
+    register_expected_print(7, "MyModel.3mf", archive_id=99, ams_mapping=None)
+
+    def execute_router(stmt, *args, **kwargs):
+        sql = str(stmt).lower()
+        if "from printers" in sql or "from printer " in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_printer),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_printer]))),
+            )
+        if "from print_archives" in sql or "from print_archive" in sql:
+            return MagicMock(
+                scalar_one_or_none=MagicMock(return_value=mock_archive),
+                scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[mock_archive]))),
+            )
+        return MagicMock(
+            scalar_one_or_none=MagicMock(return_value=None),
+            scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[]))),
+        )
+
+    mock_session = AsyncMock()
+    mock_session.__aenter__ = AsyncMock(return_value=mock_session)
+    mock_session.__aexit__ = AsyncMock()
+    mock_session.execute = AsyncMock(side_effect=execute_router)
+    mock_session.commit = AsyncMock()
+
+    with (
+        patch("backend.app.main.async_session") as mock_session_maker,
+        patch("backend.app.main.notification_service") as mock_notif,
+        patch("backend.app.main.smart_plug_manager") as mock_plug,
+        patch("backend.app.main.ws_manager") as mock_ws,
+        patch("backend.app.main.printer_manager") as mock_pm,
+        patch("backend.app.main.mqtt_relay") as mock_relay,
+        patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
+        patch("backend.app.main._load_objects_from_archive"),
+        patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
+        patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
+    ):
+        mock_session_maker.return_value = mock_session
+        mock_notif.on_print_start = AsyncMock()
+        mock_plug.on_print_start = AsyncMock()
+        mock_ws.send_print_start = AsyncMock()
+        mock_ws.send_archive_updated = AsyncMock()
+        mock_relay.on_print_start = AsyncMock()
+        mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(7, {"filename": "MyModel.3mf", "subtask_name": "MyModel"})
+
+        assert mock_archive.printer_id == 7
+        assert mock_archive.status == "printing"

+ 11 - 9
backend/tests/unit/test_run_filament_helper.py

@@ -1,23 +1,25 @@
-"""Unit tests for the per-run filament helper (#1378).
+"""Unit tests for the per-run filament helper (#1378, #1390).
 
 The helper computes what value to write into PrintLogEntry.filament_used_grams
 for a given print event — partial-aware so failed / cancelled / stopped prints
-don't inflate stats with the full slicer estimate.
+don't inflate stats with the full slicer estimate, and tracker-aware so
+completed prints agree with the per-spool counter on the Inventory page.
 """
 
 from backend.app.main import _compute_run_filament_grams
 
 
 class TestComputeRunFilamentGrams:
-    def test_completed_returns_archive_estimate(self):
-        # Completed print: the slicer estimate is approximately what was used.
+    def test_completed_no_tracker_returns_archive_estimate(self):
+        # Completed print without inventory tracking: the slicer estimate is
+        # the canonical "this print used X" value.
         assert _compute_run_filament_grams("completed", 100.0, 100, []) == 100.0
 
-    def test_completed_returns_estimate_even_when_tracked_differs(self):
-        # When a print completes, the estimate is the canonical "this print used X"
-        # value — the tracked spool delta might be lower (some slots untracked)
-        # but the print is done, so the full estimate is the right answer.
-        assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 10}]) == 100.0
+    def test_completed_prefers_tracked_over_estimate(self):
+        # #1390: when inventory tracked the AMS weight delta, Stats should
+        # reflect that — same source that drives "Total Consumed" on the
+        # Inventory page. Two halves of the app must show the same number.
+        assert _compute_run_filament_grams("completed", 100.0, 100, [{"weight_used": 96.5}]) == 96.5
 
     def test_failed_uses_tracked_spool_delta(self):
         # Failed reprint at 10g actual: inventory tracked the spool delta.

+ 80 - 0
backend/tests/unit/test_spoolman_inventory_helpers.py

@@ -102,9 +102,36 @@ class TestMapSpoolmanSpool:
         assert result["material"] == "PLA"
         assert result["rgba"] == "FF0000FF"
         assert result["label_weight"] == 1000
+        # No remaining_weight set → fallback path: weight_used = used_weight, baseline = 0.
         assert result["weight_used"] == pytest.approx(250.0)
+        assert result["weight_used_baseline"] == pytest.approx(0.0)
         assert result["data_origin"] == "spoolman"
 
+    def test_remaining_weight_drives_synthetic_used_for_parity(self):
+        """When remaining_weight is set, weight_used = label - remaining and
+        the baseline absorbs the used_weight delta. This mirrors the internal
+        Spool model's split between consumed counter and physical depletion
+        so the frontend computes the same display in both modes (#1390).
+        """
+        spool = {**MINIMAL_SPOOL, "used_weight": 250.0, "remaining_weight": 544.0}
+        result = _map_spoolman_spool(spool)
+        # Remaining = label - weight_used must equal real remaining_weight.
+        assert result["label_weight"] - result["weight_used"] == pytest.approx(544.0)
+        # Consumed = weight_used - baseline must equal real used_weight.
+        assert result["weight_used"] - result["weight_used_baseline"] == pytest.approx(250.0)
+
+    def test_remaining_weight_after_reset(self):
+        """Spoolman reset: used_weight=0, remaining_weight unchanged. The
+        mapper produces baseline = weight_used so the displayed consumed
+        counter reads 0 while remaining stays at the real value.
+        """
+        spool = {**MINIMAL_SPOOL, "used_weight": 0.0, "remaining_weight": 544.0}
+        result = _map_spoolman_spool(spool)
+        assert result["weight_used"] == pytest.approx(456.0)
+        assert result["weight_used_baseline"] == pytest.approx(456.0)
+        assert result["weight_used"] - result["weight_used_baseline"] == pytest.approx(0.0)
+        assert result["label_weight"] - result["weight_used"] == pytest.approx(544.0)
+
     def test_missing_id_raises(self):
         spool = {k: v for k, v in MINIMAL_SPOOL.items() if k != "id"}
         with pytest.raises(ValueError, match="missing required 'id'"):
@@ -218,6 +245,59 @@ class TestMapSpoolmanSpool:
         # color_name falls back to subtype.
         assert result["color_name"] == "Basic Red"
 
+    def test_color_name_read_from_spool_extra_first(self):
+        """#1357: the canonical store for color_name is
+        spool.extra.bambu_color_name (JSON-encoded). Read priority is
+        extra > filament.color_name > subtype-synth. The user's
+        Bambuddy-saved value MUST win even when Spoolman's own
+        filament.color_name happens to be populated from some other source.
+        """
+        spool = {
+            **MINIMAL_SPOOL,
+            "extra": {"bambu_color_name": '"Galaxy Black"'},
+            "filament": {
+                **MINIMAL_SPOOL["filament"],
+                "name": "PLA Glow",
+                "color_name": "Glow",  # would be picked up if extra weren't preferred
+            },
+        }
+        result = _map_spoolman_spool(spool)
+        assert result["color_name"] == "Galaxy Black"
+        assert result["color_name_is_synthesized"] is False
+
+    def test_color_name_empty_extra_falls_through_to_filament(self):
+        """An explicit empty string in spool.extra.bambu_color_name (the
+        "user cleared the field" shape) must NOT mask Spoolman's own
+        filament.color_name if one exists — it falls through to the next
+        layer instead of suppressing it."""
+        spool = {
+            **MINIMAL_SPOOL,
+            "extra": {"bambu_color_name": '""'},
+            "filament": {
+                **MINIMAL_SPOOL["filament"],
+                "color_name": "Sunset",
+            },
+        }
+        result = _map_spoolman_spool(spool)
+        assert result["color_name"] == "Sunset"
+        assert result["color_name_is_synthesized"] is False
+
+    def test_color_name_empty_extra_falls_through_to_synth(self):
+        """When extra is cleared and filament has no color_name either,
+        fall all the way through to the subtype synth — same UX as a fresh
+        Spoolman install."""
+        spool = {
+            **MINIMAL_SPOOL,
+            "extra": {"bambu_color_name": '""'},
+            "filament": {
+                **MINIMAL_SPOOL["filament"],
+                "name": "PLA Basic Red",
+            },
+        }
+        result = _map_spoolman_spool(spool)
+        assert result["color_name"] == "Basic Red"
+        assert result["color_name_is_synthesized"] is True
+
     def test_color_name_none_when_both_fields_empty(self):
         """If neither color_name nor a usable subtype exists, return None — UI
         falls back to its own 'Unknown color' string rather than showing a

+ 74 - 42
backend/tests/unit/test_spoolman_inventory_methods.py

@@ -323,26 +323,15 @@ class TestFindOrCreateFilament:
         assert result == 7
 
     @pytest.mark.asyncio
-    async def test_patches_color_name_on_existing_filament_when_changed(self, client):
-        """#1319: color_name is not part of the match key, so when a caller
-        updates a spool with a new color_name and the material/name/color/vendor
-        still match an existing filament, the existing filament's color_name
-        must be patched — otherwise the user's edit is silently dropped."""
-        existing = {**SAMPLE_FILAMENT, "color_name": None}
-        with (
-            patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
-            patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
-            patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
-        ):
-            result = await client.find_or_create_filament(
-                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
-            )
-        assert result == 7
-        mock_patch.assert_called_once_with(7, {"color_name": "Sunny Yellow"})
-
-    @pytest.mark.asyncio
-    async def test_does_not_patch_when_color_name_unchanged(self, client):
-        existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
+    async def test_color_name_does_not_trigger_filament_patch(self, client):
+        """#1357: Spoolman 0.23.1 has no `color_name` field on Filament
+        (verified against FilamentUpdateParameters schema). find_or_create_filament
+        must NOT attempt to PATCH it — the route now persists the user's
+        color_name to spool.extra.bambu_color_name instead. Any patch call
+        from this layer would be a silent no-op (Spoolman ignores unknown
+        keys) and was the original symptom of "edits never save".
+        """
+        existing = {**SAMPLE_FILAMENT}
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
@@ -355,47 +344,88 @@ class TestFindOrCreateFilament:
         mock_patch.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_does_not_patch_when_color_name_empty(self, client):
-        """An empty/None color_name should not clobber an existing value."""
-        existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
+    async def test_matches_filament_named_with_just_subtype(self, client):
+        """#1357: AMS-sync auto-create saves the filament with name set to just
+        ``tray.tray_sub_brands`` (e.g. ``"Glow"`` without the material prefix),
+        but the user-driven edit path composes ``"<material> <subtype>"``
+        (``"PLA Glow"``). Before this fix the literal `f_name == name` check
+        failed to bridge the two shapes, so every edit fell through to
+        ``create_filament`` and left a trail of duplicate filaments. Now the
+        name match strips the material prefix on both sides, so the two
+        shapes resolve to the same subtype key."""
+        existing = {
+            **SAMPLE_FILAMENT,
+            "id": 11,
+            "name": "Glow",  # AMS-sync shape: just subtype
+            "material": "PLA",
+            "color_hex": "AAF3C6",
+            "color_name": None,
+            "vendor": {"id": 3, "name": "Amazon Basics"},
+        }
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
             patch.object(client, "patch_filament", AsyncMock()) as mock_patch,
+            patch.object(client, "create_filament", AsyncMock()) as mock_create,
         ):
-            result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name=None)
-        assert result == 7
+            result = await client.find_or_create_filament(
+                "PLA", "Glow", "Amazon Basics", "AAF3C6", 1000, color_name="Bright Glow"
+            )
+        assert result == 11
+        # color_name is no longer written via the filament — see #1357 — and
+        # the function must not create a duplicate filament.
         mock_patch.assert_not_called()
+        mock_create.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_clears_color_name_when_empty_string_passed(self, client):
-        """#1319 follow-up: empty string means "explicit clear" — the route
-        layer translates a wire-level null into "" so the user can blank the
-        field on a previously-set spool."""
-        existing = {**SAMPLE_FILAMENT, "color_name": "Sunny Yellow"}
+    async def test_still_matches_filament_named_material_plus_subtype(self, client):
+        """The composed-name shape (``"PLA Basic"`` matching a Spoolman filament
+        also named ``"PLA Basic"``) must keep working — the normalisation strips
+        the prefix on both sides, so the comparison is on the subtype part."""
+        existing = {
+            **SAMPLE_FILAMENT,
+            "id": 7,
+            "name": "PLA Basic",
+            "material": "PLA",
+            "color_hex": "FF0000",
+            "color_name": "Sunset",
+        }
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
-            patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})) as mock_patch,
+            patch.object(client, "patch_filament", AsyncMock(return_value={"id": 7})),
+            patch.object(client, "create_filament", AsyncMock()) as mock_create,
         ):
-            result = await client.find_or_create_filament("PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="")
+            result = await client.find_or_create_filament(
+                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
+            )
         assert result == 7
-        mock_patch.assert_called_once_with(7, {"color_name": None})
+        mock_create.assert_not_called()
 
     @pytest.mark.asyncio
-    async def test_patch_failure_does_not_block_match(self, client):
-        """A patch_filament failure must not prevent returning the matched id —
-        save should still link the spool to the correct filament."""
-        existing = {**SAMPLE_FILAMENT, "color_name": None}
+    async def test_name_match_does_not_cross_materials(self, client):
+        """Sanity check: a filament with name=subtype must NOT match a request
+        with a different material that happens to share the subtype string.
+        material_match runs first and fails, so the iteration moves on and
+        ``create_filament`` is called."""
+        existing = {
+            **SAMPLE_FILAMENT,
+            "id": 7,
+            "name": "Basic",
+            "material": "PETG",  # different material
+            "color_hex": "FF0000",
+        }
+        new_filament = {"id": 99, "name": "PLA Basic"}
         with (
             patch.object(client, "find_or_create_vendor", AsyncMock(return_value=3)),
             patch.object(client, "get_filaments", AsyncMock(return_value=[existing])),
-            patch.object(client, "patch_filament", AsyncMock(side_effect=SpoolmanUnavailableError("boom"))),
+            patch.object(client, "create_filament", AsyncMock(return_value=new_filament)) as mock_create,
         ):
             result = await client.find_or_create_filament(
-                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunny Yellow"
+                "PLA", "Basic", "Bambu Lab", "FF0000", 1000, color_name="Sunset"
             )
-        assert result == 7
+        assert result == 99
+        mock_create.assert_called_once()
 
     @pytest.mark.asyncio
     async def test_creates_filament_when_no_match(self, client):
@@ -407,12 +437,15 @@ class TestFindOrCreateFilament:
         ):
             result = await client.find_or_create_filament("PETG", "Pro", "Bambu Lab", "00FF00", 1000)
         assert result == 99
+        # color_name is intentionally not forwarded to create_filament (#1357):
+        # Spoolman has no such field on Filament, so passing it would be a
+        # no-op. The route persists color_name to spool.extra.bambu_color_name
+        # after this returns.
         mock_create.assert_called_once_with(
             name="PETG Pro",
             vendor_id=3,
             material="PETG",
             color_hex="00FF00",
-            color_name=None,
             weight=1000.0,
         )
 
@@ -443,7 +476,6 @@ class TestFindOrCreateFilament:
             vendor_id=None,
             material="ABS",
             color_hex="FF0000",
-            color_name=None,
             weight=750.0,
         )
 

+ 183 - 0
backend/tests/unit/test_vp_mqtt_bridge.py

@@ -306,6 +306,189 @@ class TestPushStatusCache:
 
         await bridge.stop()
 
+    @pytest.mark.asyncio
+    async def test_partial_ams_status_update_preserves_unit_list(self):
+        """#1387: Bambu firmware also sends `ams` updates where the key is
+        present but the inner `ams` array is missing — e.g. just
+        ``{ams_status: 1}`` or a humidity change. Before the deep-merge fix
+        the bridge would overwrite the cached AMS with this stripped blob,
+        the slicer would read it on the next 1 Hz push, and BambuStudio
+        would drop the unit list and fall back to its "no AMS" render
+        (only the external spool visible — the reporter's exact symptom).
+        Now the partial update only mutates the fields it carries; the
+        cached unit list survives.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Pushall with full AMS state.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "0",
+                                    "humidity": "1",
+                                    "tray": [{"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"}],
+                                }
+                            ],
+                            "tray_exist_bits": "1",
+                            "ams_status": "0",
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        # 2. Partial AMS update — only `ams_status` and `humidity` changed.
+        # No `ams.ams` array, so prev's unit list must be preserved.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams_status": "1", "humidity": "2"},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        # Scalar fields take the new values.
+        assert cached["ams"]["ams_status"] == "1"
+        assert cached["ams"]["humidity"] == "2"
+        # Unit + tray data preserved from the pushall.
+        assert cached["ams"]["tray_exist_bits"] == "1"
+        assert len(cached["ams"]["ams"]) == 1
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_type"] == "PLA"
+        assert cached["ams"]["ams"][0]["tray"][0]["tray_color"] == "FF0000FF"
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_partial_ams_unit_update_preserves_other_units(self):
+        """#1387: when multiple AMS units are configured (e.g. H2D with two
+        AMS), an incremental push during a print typically only carries the
+        unit / tray that changed state. Naive replacement of `ams.ams` wipes
+        the other unit. The bridge merges unit-by-unit by id, preserving
+        units the incremental doesn't mention.
+        """
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        # 1. Pushall with two AMS units configured.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {"id": "0", "tray": [{"id": "0", "tray_type": "PLA"}]},
+                                {"id": "1", "tray": [{"id": "0", "tray_type": "PETG"}]},
+                            ],
+                            "tray_exist_bits": "3",
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        # 2. Tray-targeted incremental: unit 0 / tray 0 state changed.
+        # Unit 1 is not in the update — must survive.
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        units = {u["id"]: u for u in cached["ams"]["ams"]}
+        # Unit 0 keeps its tray_type from the pushall + picks up the new state.
+        assert units["0"]["tray"][0]["tray_type"] == "PLA"
+        assert units["0"]["tray"][0]["state"] == "11"
+        # Unit 1 survives the incremental.
+        assert "1" in units
+        assert units["1"]["tray"][0]["tray_type"] == "PETG"
+
+        await bridge.stop()
+
+    @pytest.mark.asyncio
+    async def test_partial_ams_tray_update_preserves_other_trays(self):
+        """Same shape as the unit-level test but at the tray level. AMS
+        unit 0 has four trays; the incremental only mentions tray 0.
+        Trays 1-3 must survive intact."""
+        server = _make_server()
+        bridge = _make_bridge(server)
+        await bridge.start()
+
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {
+                            "ams": [
+                                {
+                                    "id": "0",
+                                    "tray": [
+                                        {"id": "0", "tray_type": "PLA", "tray_color": "FF0000FF"},
+                                        {"id": "1", "tray_type": "PETG", "tray_color": "00FF00FF"},
+                                        {"id": "2", "tray_type": "ABS", "tray_color": "0000FFFF"},
+                                        {"id": "3", "tray_type": "TPU", "tray_color": "FFFF00FF"},
+                                    ],
+                                }
+                            ],
+                        },
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        bridge._on_printer_raw(
+            f"device/{H2D_SERIAL}/report",
+            json.dumps(
+                {
+                    "print": {
+                        "command": "push_status",
+                        "ams": {"ams": [{"id": "0", "tray": [{"id": "0", "state": "11"}]}]},
+                    }
+                }
+            ).encode(),
+        )
+        await asyncio.sleep(0.01)
+
+        cached = bridge.get_latest_print_state()
+        trays = {t["id"]: t for t in cached["ams"]["ams"][0]["tray"]}
+        assert trays["0"]["tray_type"] == "PLA"
+        assert trays["0"]["state"] == "11"
+        # Trays not mentioned in the incremental survive intact.
+        assert trays["1"]["tray_type"] == "PETG"
+        assert trays["2"]["tray_type"] == "ABS"
+        assert trays["3"]["tray_type"] == "TPU"
+
+        await bridge.stop()
+
     @pytest.mark.asyncio
     async def test_incoming_ams_update_replaces_cached_ams(self):
         """Counterpart to the #1371 fix: preservation only kicks in when the

+ 32 - 0
deploy/docker-entrypoint.sh

@@ -31,6 +31,38 @@ set -eu
 PUID="${PUID:-1000}"
 PGID="${PGID:-1000}"
 
+# If requested, update and use the system trust store inside the container.
+# Users can set USE_SYSTEM_TRUST_STORE to any non-empty value to enable.
+if [ -n "${USE_SYSTEM_TRUST_STORE:-}" ]; then
+    echo "[entrypoint] USE_SYSTEM_TRUST_STORE is set"
+    if [ "$(id -u)" -ne 0 ]; then
+        echo "[entrypoint] error: USE_SYSTEM_TRUST_STORE is set but not running as root; cannot update trust store"
+        exit 1
+    fi
+    # Check if we have any certificates to process. Error if directory is empty
+    if ls -1 /usr/local/share/ca-certificates/*.crt >/dev/null 2>&1; then
+        echo "[entrypoint] .crt files found in /usr/local/share/ca-certificates"
+    else
+        echo "[entrypoint] no .crt files in /usr/local/share/ca-certificates"
+        exit 1
+    fi
+    if command -v update-ca-certificates >/dev/null 2>&1; then
+        echo "[entrypoint] update-ca-certificates found; updating system trust store"
+        if update-ca-certificates --fresh ; then
+            echo "[entrypoint] update-ca-certificates succeeded; exporting SSL_CERT_DIR=/etc/ssl/certs"
+            export SSL_CERT_DIR="/etc/ssl/certs"
+        else
+            echo "[entrypoint] error: update-ca-certificates failed"
+            exit 1
+        fi
+    else
+        echo "[entrypoint] error: update-ca-certificates not found; cannot update trust store"
+        exit 1
+    fi
+else
+    echo "[entrypoint] USE_SYSTEM_TRUST_STORE not set; skipping system trust store update"
+fi
+
 # If we're not root, we can't chown anything. Exec the original command
 # and trust that the user has set up host-side ownership themselves.
 if [ "$(id -u)" -ne 0 ]; then

+ 11 - 0
docker-compose.yml

@@ -62,6 +62,13 @@ services:
       # Without this mount, the Tailscale toggle in the UI is harmless —
       # Bambuddy falls back to self-signed certs.
       #- /var/run/tailscale/tailscaled.sock:/var/run/tailscale/tailscaled.sock
+      #
+      # Using a self signed certificate for Home Assistant
+      # Add your certificate to certs directory and mount it to the container.
+      # The certificate will be added to the system trust store on container startup.
+      # Enable the system trust store with the USE_SYSTEM_TRUST_STORE env var to
+      # have Bambuddy trust the certificate.
+      # - /path/to/certs:/usr/local/share/ca-certificates
     environment:
       - TZ=${TZ:-Europe/Berlin}
       # User/group the container drops to after the entrypoint normalises
@@ -93,6 +100,10 @@ services:
       # DATA_DIR/.mfa_encryption_key on first startup if unset. Override here
       # to manage the key out-of-band (e.g. via a secret manager).
       #- MFA_ENCRYPTION_KEY=
+      #
+      # Enable System Trust Store for certificate validation (e.g. for local Home Assistant)
+      # You also need to mount your certificates to the container (see volumes section above).
+      # - USE_SYSTEM_TRUST_STORE=true
     restart: unless-stopped
 
   # Optional: External PostgreSQL database

+ 8 - 7
frontend/scripts/check-i18n-parity.mjs

@@ -160,10 +160,11 @@ const DE_COGNATES = [
   'Hex', 'Warm', 'Neutral', 'Navigation', 'Screenshot', 'Architecture',
   'Backend & Auth', 'Stream Overlay', 'Bambuddy Backend URL',
   'Material (optional)', 'Custom Headers (JSON)', '({{count}}/8)',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'China', 'Proxy', 'Start',
+  'Diagnose',  // DE: same spelling/meaning as EN — camera diagnostic button label
 ];
 
 // French cognates — many UI labels overlap with English exactly.
@@ -191,7 +192,7 @@ const FR_COGNATES = [
   '{{count}} filament', '{{count}} filaments', '{{count}} permissions',
   '{{count}} downloads', '{{count}} item', '{{count}} selected',
   '({{count}} item)', 'Provisioning...', 'Pressure Advance',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   '({{count}}/8)', 'Custom Headers (JSON)', 'Permissions',
@@ -222,7 +223,7 @@ const IT_COGNATES = [
   '{{name}} - Timelapse', 'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
-  'AMS holder (30 × 15 mm)', 'Hex: #{{hex}}',
+  'Hex: #{{hex}}',
   'EC984C,#6CD4BC,A66EB9,D87694',
   'Proxy', 'Designer',
 ];
@@ -233,7 +234,7 @@ const JA_COGNATES = [
   'OK', 'Bambu', 'Code',
   'EU (DD/MM/YYYY)', 'US (MM/DD/YYYY)', 'ON, true, 1',
   '({{count}}/8)', 'Custom Headers (JSON)',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'EC984C,#6CD4BC,A66EB9,D87694',
@@ -256,7 +257,7 @@ const PT_BR_COGNATES = [
   'Base: {{name}}', 'ETA {{minutes}} min', '{{count}} item',
   '{{count}} downloads', '({{count}} item)', '(25%, 50%, 75%)',
   '({{count}}/8)', 'Custom Headers (JSON)', '{{name}} - Timelapse',
-  'AMS holder (30 × 15 mm)', 'Box label (62 × 29 mm)',
+  'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
   'Cancelling upload...', 'EC984C,#6CD4BC,A66EB9,D87694',
@@ -268,7 +269,7 @@ const PT_BR_COGNATES = [
 // Chinese (Simplified): very few cognates beyond brand names.
 const ZH_CN_COGNATES = [
   'OK', 'Bambu',
-  '({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
+  '({{count}}/8)', 'Custom Headers (JSON)',
   'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',
@@ -277,7 +278,7 @@ const ZH_CN_COGNATES = [
 
 const ZH_TW_COGNATES = [
   'OK', 'Bambu',
-  '({{count}}/8)', 'Custom Headers (JSON)', 'AMS holder (30 × 15 mm)',
+  '({{count}}/8)', 'Custom Headers (JSON)',
   'Box label (62 × 29 mm)',
   'Avery L7160 — A4 sheet (38.1 × 63.5 mm × 21)',
   'Avery 5160 — US Letter sheet (25.4 × 66.7 mm × 30)',

+ 16 - 0
frontend/src/__tests__/api/client.test.ts

@@ -198,6 +198,22 @@ describe('API Client Auth Header', () => {
   });
 });
 
+describe('Slicer download URLs', () => {
+  it('keeps library slicer URLs ending in .3mf when the display name has no extension', () => {
+    const path = api.getLibrarySlicerDownloadUrl(12, 'token-abc', 'Mecha Mewtwo No AMS Multi Color Parted Statue');
+
+    expect(path).toBe(
+      '/api/v1/library/files/12/dl/token-abc/Mecha%20Mewtwo%20No%20AMS%20Multi%20Color%20Parted%20Statue.3mf'
+    );
+  });
+
+  it('sanitizes library slicer URL filenames before encoding them', () => {
+    const path = api.getLibrarySlicerDownloadUrl(12, 'token-abc', 'folder/model?bad#name.3mf');
+
+    expect(path).toBe('/api/v1/library/files/12/dl/token-abc/folder_model_bad_name.3mf');
+  });
+});
+
 describe('FormData requests include auth header', () => {
   it('importProjectFile includes Authorization header', async () => {
     // Mock fetch directly for FormData requests (MSW can be flaky with multipart in some environments)

+ 38 - 0
frontend/src/__tests__/components/AssignSpoolModal.test.tsx

@@ -9,9 +9,12 @@ vi.mock('../../api/client', () => ({
     getSpools: vi.fn(),
     getAssignments: vi.fn(),
     assignSpool: vi.fn(),
+    assignSpoolmanSlot: vi.fn(),
     getSpoolmanInventorySpools: vi.fn(),
+    getSpoolmanSlotAssignments: vi.fn().mockResolvedValue([]),
     getSettings: vi.fn().mockResolvedValue({}),
     getAuthStatus: vi.fn().mockResolvedValue({ auth_enabled: false }),
+    refreshPrinterStatus: vi.fn().mockResolvedValue({ status: 'ok' }),
   },
 }));
 
@@ -285,6 +288,41 @@ describe('AssignSpoolModal', () => {
       expect(screen.getByText(/Devil Design/)).toBeInTheDocument();
     });
   });
+
+  it('nudges the printer to republish after successful assignment (#1414)', async () => {
+    // The backend's assign-spool path issues an MQTT command, but firmware
+    // (esp. A1 mini external slots and any non-RFID assignment) doesn't
+    // always echo the new tray state back on its own — the printer card
+    // then sits on stale data until the user hits Force-refresh. Modal
+    // calls refreshPrinterStatus to issue a pushall so the printer
+    // republishes state, mirroring the Force-refresh button.
+    const { default: userEvent } = await import('@testing-library/user-event');
+    const user = userEvent.setup();
+
+    // Tray material matches the spool to skip the mismatch confirm dialog.
+    (api.getSpools as ReturnType<typeof vi.fn>).mockResolvedValue([manualSpool]);
+    (api.assignSpool as ReturnType<typeof vi.fn>).mockResolvedValue({
+      id: 1, spool_id: 1, printer_id: 7, ams_id: 0, tray_id: 0,
+    });
+
+    render(
+      <AssignSpoolModal
+        {...defaultProps}
+        printerId={7}
+        trayInfo={{ type: 'PLA', material: 'PLA', profile: 'PLA', color: 'FF0000', location: 'AMS 1 - Slot 1' }}
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText(/Polymaker/)).toBeInTheDocument();
+    });
+    await user.click(screen.getByText(/Polymaker/));
+    await user.click(screen.getByRole('button', { name: /assign spool/i }));
+
+    await waitFor(() => {
+      expect(api.refreshPrinterStatus).toHaveBeenCalledWith(7);
+    });
+  });
 });
 
 describe('AssignSpoolModal — Spoolman enabled (T-Gap 7)', () => {

+ 123 - 0
frontend/src/__tests__/components/CameraDiagnoseModal.test.tsx

@@ -0,0 +1,123 @@
+/**
+ * Tests for the camera diagnostic modal (#1395 follow-up).
+ *
+ * Covers the three observable behaviours that matter for user-facing
+ * triage: the modal kicks off the diagnostic on mount, renders per-
+ * stage results when the API replies, and maps the summary code to a
+ * translated remediation hint. Each test mocks the API client so the
+ * suite never actually opens a socket.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen, waitFor, fireEvent } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { I18nextProvider } from 'react-i18next';
+import i18n from '../../i18n';
+import { CameraDiagnoseModal } from '../../components/CameraDiagnoseModal';
+import { api, type CameraDiagnoseResult } from '../../api/client';
+
+function renderModal() {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+  });
+  const onClose = vi.fn();
+  render(
+    <QueryClientProvider client={queryClient}>
+      <I18nextProvider i18n={i18n}>
+        <CameraDiagnoseModal printerId={1} printerName="Test P2S" onClose={onClose} />
+      </I18nextProvider>
+    </QueryClientProvider>,
+  );
+  return { onClose };
+}
+
+describe('CameraDiagnoseModal', () => {
+  it('runs the diagnostic on mount and shows per-stage results', async () => {
+    const okResult: CameraDiagnoseResult = {
+      printer_id: 1,
+      protocol: 'rtsp',
+      port: 322,
+      profile: 'P2S',
+      overall_status: 'ok',
+      stages: [
+        { name: 'tcp_reachable', status: 'ok', duration_ms: 12, code: null },
+        { name: 'first_frame', status: 'ok', duration_ms: 1230, code: null },
+      ],
+      summary_code: 'all_ok',
+    };
+    const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(okResult);
+
+    renderModal();
+
+    // Mounted → API called once
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+
+    // Stage names render via i18n
+    expect(await screen.findByText(/Network reachability/i)).toBeInTheDocument();
+    expect(screen.getByText(/Frame capture/i)).toBeInTheDocument();
+
+    // Per-stage duration is shown for support triage
+    expect(screen.getByText(/12 ms/i)).toBeInTheDocument();
+    expect(screen.getByText(/1230 ms/i)).toBeInTheDocument();
+
+    // Summary remediation message is rendered translated
+    expect(screen.getByText(/Camera is working/i)).toBeInTheDocument();
+
+    // Metadata for support triage
+    expect(screen.getByText('rtsp')).toBeInTheDocument();
+    expect(screen.getByText('322')).toBeInTheDocument();
+    expect(screen.getByText('P2S')).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('maps a failure summary code to a translated remediation hint', async () => {
+    const failedResult: CameraDiagnoseResult = {
+      printer_id: 1,
+      protocol: 'rtsp',
+      port: 322,
+      profile: 'P2S',
+      overall_status: 'failed',
+      stages: [
+        { name: 'tcp_reachable', status: 'failed', duration_ms: 3001, code: 'tcp_timeout' },
+        { name: 'first_frame', status: 'skipped', duration_ms: 0, code: null },
+      ],
+      summary_code: 'printer_unreachable',
+    };
+    const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(failedResult);
+
+    renderModal();
+
+    // The remediation hint for printer_unreachable mentions IP / network /
+    // power — the user-facing fix-it instructions, not the raw summary code.
+    expect(await screen.findByText(/IP address/i)).toBeInTheDocument();
+
+    // The machine-readable stage code is also surfaced (small font) for
+    // support triage so users can paste it into a ticket.
+    expect(screen.getByText('tcp_timeout')).toBeInTheDocument();
+
+    spy.mockRestore();
+  });
+
+  it('re-runs the diagnostic when the user clicks Run again', async () => {
+    const okResult: CameraDiagnoseResult = {
+      printer_id: 1,
+      protocol: 'rtsp',
+      port: 322,
+      profile: 'P2S',
+      overall_status: 'ok',
+      stages: [{ name: 'tcp_reachable', status: 'ok', duration_ms: 12, code: null }],
+      summary_code: 'all_ok',
+    };
+    const spy = vi.spyOn(api, 'diagnoseCamera').mockResolvedValue(okResult);
+
+    renderModal();
+
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
+
+    fireEvent.click(screen.getByText(/Run again/i));
+    await waitFor(() => expect(spy).toHaveBeenCalledTimes(2));
+
+    spy.mockRestore();
+  });
+});

+ 15 - 0
frontend/src/__tests__/components/FilamentHoverCard.test.tsx

@@ -310,6 +310,21 @@ describe('FilamentHoverCard', () => {
       });
       expect(screen.queryAllByTitle('Open in Inventory')).toHaveLength(1);
     });
+
+    it('shows the spool ID in the assigned-spool block', async () => {
+      renderWithHover(
+        <FilamentHoverCard
+          data={baseFilamentData}
+          inventory={{ assignedSpool: inventorySpool }}
+        >
+          <div>trigger</div>
+        </FilamentHoverCard>
+      );
+      vi.advanceTimersByTime(100);
+      await waitFor(() => {
+        expect(screen.getByText('#42')).toBeInTheDocument();
+      });
+    });
   });
 });
 

+ 76 - 7
frontend/src/__tests__/components/LabelTemplatePickerModal.test.tsx

@@ -176,7 +176,13 @@ describe('LabelTemplatePickerModal', () => {
         spoolmanMode={false}
       />,
     );
-    expect(screen.getByText(/AMS holder/i).closest('button')).toBeDisabled();
+    // Two AMS holder variants exist (#1426). Both must be disabled when no
+    // spools are selected — the empty-selection guard is global, not per-template.
+    const amsButtons = screen.getAllByText(/AMS holder/i).map((el) => el.closest('button'));
+    expect(amsButtons).toHaveLength(2);
+    for (const btn of amsButtons) {
+      expect(btn).toBeDisabled();
+    }
   });
 
   it('sends only the currently checked IDs to the local endpoint', async () => {
@@ -218,12 +224,14 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
-    fireEvent.click(screen.getByText(/AMS holder/i));
+    // Pick the larger AMS holder variant explicitly (#1426: two AMS templates
+     // exist now — pin which one the test sends so the assertion stays meaningful).
+    fireEvent.click(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i));
 
     await waitFor(() => {
       expect(api.printSpoolmanSpoolLabels).toHaveBeenCalledWith({
         spool_ids: [1],
-        template: 'ams_30x15',
+        template: 'ams_holder_75x55',
       });
     });
     expect(api.printSpoolLabels).not.toHaveBeenCalled();
@@ -296,9 +304,10 @@ describe('LabelTemplatePickerModal', () => {
       />,
     );
 
-    // All five templates must be in the DOM. Use the dimension suffix to
-    // disambiguate the two "Box label …" entries.
-    expect(screen.getByText(/AMS holder/i)).toBeInTheDocument();
+    // All six templates must be in the DOM (#1426 added two AMS variants).
+    // Use the dimension suffix to disambiguate same-family entries.
+    expect(screen.getByText(/AMS holder — small \(74 × 33 mm\)/i)).toBeInTheDocument();
+    expect(screen.getByText(/AMS holder — large \(75 × 55 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Box label \(40 × 30 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Box label \(62 × 29 mm\)/i)).toBeInTheDocument();
     expect(screen.getByText(/Avery L7160/i)).toBeInTheDocument();
@@ -311,7 +320,7 @@ describe('LabelTemplatePickerModal', () => {
     const templatesSection = container.querySelector('div.grid.sm\\:grid-cols-2');
     expect(templatesSection).not.toBeNull();
     expect(templatesSection!.className).toContain('grid-cols-1');
-    expect(templatesSection!.querySelectorAll('button').length).toBe(5);
+    expect(templatesSection!.querySelectorAll('button').length).toBe(6);
 
     // Spool list still uses min-h-0 so it can yield further on very tight viewports.
     const spoolListScroller = container.querySelector('div.flex-1.overflow-y-auto');
@@ -319,4 +328,64 @@ describe('LabelTemplatePickerModal', () => {
     expect(spoolListScroller!.className).toContain('min-h-0');
     expect(spoolListScroller!.className).not.toMatch(/min-h-\[\d/);
   });
+
+  // #1410: an "ID | colour" sort toggle in the modal must flow through to the
+  // PDF — the backend (labels.py) prints in the order it receives spool_ids,
+  // so the modal's "submit in ID order" default was forcing every PDF to
+  // appear in spool-number order regardless of user choice. Toggling to
+  // colour mode must reorder both the visible list AND the payload so the
+  // printed sheet groups colours together.
+  it('sorts the submit payload by HSL hue when sort mode is "By colour" (#1410)', async () => {
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3, 4]}  // Red / Blue / Black / Ivory all picked
+        spoolmanMode={false}
+      />,
+    );
+
+    // Default is ID-sorted; flip to colour.
+    fireEvent.click(screen.getByRole('button', { name: 'By colour' }));
+    fireEvent.click(screen.getByText(/Box label \(62 × 29 mm\)/i));
+
+    await waitFor(() => {
+      // Expected colour-sort order for the SPOOLS fixture:
+      //   Red    (1) — hue 0°   — chromatic
+      //   Ivory  (4) — hue ≈34° — chromatic
+      //   Blue   (2) — hue 240° — chromatic
+      //   Black  (3) — saturation ≈0 → neutrals bucket, lightness 0 → last
+      // Rainbow first, then neutrals (dark→light) per design choice for #1410.
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1, 4, 2, 3],
+        template: 'box_62x29',
+      });
+    });
+  });
+
+  it('keeps ID-order submission by default (#1410 regression guard)', async () => {
+    // Adding the sort toggle must NOT change the default behaviour — IDs go
+    // in ascending order unless the user explicitly clicks "By colour".
+    vi.mocked(api.printSpoolLabels).mockResolvedValue(PDF_BLOB);
+    render(
+      <LabelTemplatePickerModal
+        isOpen={true}
+        onClose={vi.fn()}
+        availableSpools={SPOOLS}
+        initialSelectedIds={[1, 2, 3, 4]}
+        spoolmanMode={false}
+      />,
+    );
+
+    fireEvent.click(screen.getByText(/Box label \(40 × 30 mm\)/i));
+
+    await waitFor(() => {
+      expect(api.printSpoolLabels).toHaveBeenCalledWith({
+        spool_ids: [1, 2, 3, 4],
+        template: 'box_40x30',
+      });
+    });
+  });
 });

+ 12 - 417
frontend/src/__tests__/components/SpoolCatalogSettings.test.tsx

@@ -1,6 +1,5 @@
-import React from 'react';
 import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, fireEvent } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
 import { render } from '../utils';
 import { SpoolCatalogSettings } from '../../components/SpoolCatalogSettings';
 
@@ -20,17 +19,6 @@ vi.mock('../../api/client', () => ({
   api: {
     getSettings: vi.fn().mockResolvedValue({}),
     getSpoolCatalog: vi.fn().mockResolvedValue([]),
-    getSpoolmanInventoryFilaments: vi.fn().mockResolvedValue([]),
-    patchSpoolmanFilament: vi.fn().mockResolvedValue({
-      id: 1,
-      name: 'PLA Basic',
-      material: 'PLA',
-      color_hex: 'FF0000',
-      color_name: 'Red',
-      weight: 1000,
-      spool_weight: 196,
-      vendor: { id: 1, name: 'Bambu Lab' },
-    }),
   },
   ApiError: class ApiError extends Error {
     status: number;
@@ -41,87 +29,15 @@ vi.mock('../../api/client', () => ({
   },
 }));
 
-import { api, ApiError } from '../../api/client';
+import { api } from '../../api/client';
 
-const sampleFilament = {
-  id: 1,
-  name: 'PLA Basic',
-  material: 'PLA',
-  color_hex: 'FF0000',
-  color_name: 'Red',
-  weight: 1000,
-  spool_weight: 196,
-  vendor: { id: 1, name: 'Bambu Lab' },
-};
-
-describe('SpoolCatalogSettings — mode switching', () => {
+describe('SpoolCatalogSettings — local catalog UI', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.mocked(api.getSpoolCatalog).mockResolvedValue([]);
   });
 
-  it('hides Spoolman table and shows local CRUD buttons when Spoolman is disabled (400)', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
-      new ApiError('disabled', 400)
-    );
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      // Local mode: Add button visible
-      expect(screen.getByText('common.add')).toBeTruthy();
-    });
-
-    // Spoolman table columns must NOT appear
-    expect(screen.queryByText('settings.catalog.material')).toBeNull();
-    expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
-    // Spoolman catalog title must NOT appear
-    expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
-  });
-
-  it('shows Spoolman error row when Spoolman is unreachable (503)', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
-      new ApiError('unreachable', 503)
-    );
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('inventory.spoolmanCatalogLoadFailed')).toBeTruthy();
-    });
-
-    // Local CRUD buttons must NOT appear in Spoolman mode
-    expect(screen.queryByText('common.add')).toBeNull();
-  });
-
-  it('shows empty state when Spoolman returns an empty list', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('inventory.noSpoolmanFilaments')).toBeTruthy();
-    });
-
-    // Local CRUD buttons must NOT appear
-    expect(screen.queryByText('common.add')).toBeNull();
-  });
-
-  it('renders Spoolman filament rows with vendor and name combined', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-  });
-
-  it('(local mode) shows Export, Import, Reset, Add buttons when Spoolman disabled', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockRejectedValue(
-      new ApiError('disabled', 400)
-    );
-
+  it('shows local CRUD buttons regardless of Spoolman state', async () => {
     render(<SpoolCatalogSettings />);
 
     await waitFor(() => {
@@ -133,341 +49,20 @@ describe('SpoolCatalogSettings — mode switching', () => {
     expect(screen.getByText('common.reset')).toBeTruthy();
   });
 
-  it('(spoolman mode) hides Export, Import, Reset, Add buttons when Spoolman is enabled', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    expect(screen.queryByText('common.add')).toBeNull();
-    expect(screen.queryByText('common.export')).toBeNull();
-    expect(screen.queryByText('common.import')).toBeNull();
-    expect(screen.queryByText('common.reset')).toBeNull();
-  });
-
-  it('(spoolman mode) renders correct column headers — Name, Material, Weight, Spool Weight', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
+  it('renders the local Spool Catalog header and column layout', async () => {
     render(<SpoolCatalogSettings />);
 
     await waitFor(() => {
-      expect(screen.getByText('common.name')).toBeTruthy();
+      expect(screen.getByText('settings.catalog.spoolCatalog')).toBeTruthy();
     });
 
-    expect(screen.getByText('settings.catalog.material')).toBeTruthy();
+    expect(screen.getByText('common.name')).toBeTruthy();
     expect(screen.getByText('settings.catalog.weight')).toBeTruthy();
-    expect(screen.getByText('settings.catalog.spoolWeight')).toBeTruthy();
-  });
-
-  it('(spoolman mode) renders all data fields for a filament row', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    // Material column
-    expect(screen.getByText('PLA')).toBeTruthy();
-    // Filament weight
-    expect(screen.getByText('1000g')).toBeTruthy();
-    // Spool (empty) weight
-    expect(screen.getByText('196g')).toBeTruthy();
-  });
-
-  it('(spoolman mode) renders color swatch with correct background color', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
-      { ...sampleFilament, color_hex: 'FF5500' },
-    ]);
+    expect(screen.getByText('settings.catalog.type')).toBeTruthy();
 
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
-    const bg = (swatch as HTMLElement).style.backgroundColor;
-    // Accepts both hex-like and rgb() representations
-    expect(bg).toBeTruthy();
-    expect(bg).not.toBe('');
-  });
-
-  it('(spoolman mode) renders fallback color when color_hex is null', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
-      { ...sampleFilament, color_hex: null },
-    ]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    const swatch = screen.getByLabelText('inventory.spoolmanFilamentColorSwatch');
-    expect((swatch as HTMLElement).style.backgroundColor).toContain('128');
-  });
-
-  it('(spoolman mode) renders dash for null material, weight, and spool_weight', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([
-      { ...sampleFilament, material: null, weight: null, spool_weight: null },
-    ]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    // All three nullable fields must show '—', not 'nullg' or empty string
-    const dashes = screen.getAllByText('—');
-    expect(dashes.length).toBeGreaterThanOrEqual(3);
-  });
-
-  it('(spoolman mode) shows Spoolman catalog title, not local catalog title', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText('settings.spoolmanFilamentCatalogTitle')).toBeTruthy();
-    });
-
-    expect(screen.queryByText('settings.catalog.spoolCatalog')).toBeNull();
-  });
-
-  it('(spoolman mode) shows pencil edit button in each filament row', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByText(/Bambu Lab — PLA Basic/)).toBeTruthy();
-    });
-
-    const editButtons = screen.getAllByLabelText('common.edit');
-    expect(editButtons.length).toBeGreaterThanOrEqual(1);
-  });
-
-  it('(spoolman mode) clicking pencil shows name and weight inputs', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.name')).toBeTruthy();
-      expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
-    });
-  });
-
-  it('(spoolman mode) name input is pre-filled with current name', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      const nameInput = screen.getByLabelText('common.name') as HTMLInputElement;
-      expect(nameInput.value).toBe('PLA Basic');
-    });
-  });
-
-  it('(spoolman mode) weight input is pre-filled with current spool_weight', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      const weightInput = screen.getByLabelText('settings.catalog.spoolWeight') as HTMLInputElement;
-      expect(weightInput.value).toBe('196');
-    });
-  });
-
-  it('(spoolman mode) cancel edit restores read-only display', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.cancel')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.cancel'));
-
-    await waitFor(() => {
-      expect(screen.queryByLabelText('common.name')).toBeNull();
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-  });
-
-  it('(spoolman mode) empty name input disables save button', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.name')).toBeTruthy();
-    });
-
-    const nameInput = screen.getByLabelText('common.name');
-    fireEvent.change(nameInput, { target: { value: '' } });
-
-    const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
-    expect(saveBtn.disabled).toBe(true);
-  });
-
-  it('(spoolman mode) saving name-only calls patchSpoolmanFilament without modal', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.name')).toBeTruthy();
-    });
-
-    const nameInput = screen.getByLabelText('common.name');
-    fireEvent.change(nameInput, { target: { value: 'PLA Basic Renamed' } });
-
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => {
-      expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(1, { name: 'PLA Basic Renamed' });
-    });
-
-    // Modal must NOT appear
-    expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
-  });
-
-  it('(spoolman mode) saving changed spool_weight opens SpoolWeightUpdateModal', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('common.edit')).toBeTruthy();
-    });
-
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => {
-      expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy();
-    });
-
-    const weightInput = screen.getByLabelText('settings.catalog.spoolWeight');
-    fireEvent.change(weightInput, { target: { value: '100' } });
-
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => {
-      expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
-    });
-  });
-
-  it('(spoolman mode) confirming option B calls patchSpoolmanFilament with keep_existing_spools=false', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
-    fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
-
-    // Confirm with option B selected by default
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    await waitFor(() => {
-      expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
-        1,
-        expect.objectContaining({ spool_weight: 100, keep_existing_spools: false }),
-      );
-    });
-  });
-
-  it('(spoolman mode) confirming option A calls patchSpoolmanFilament with keep_existing_spools=true', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
-    fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '100' } });
-    fireEvent.click(screen.getByLabelText('common.save'));
-
-    await waitFor(() => { expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy(); });
-
-    // Select option A (keep existing)
-    const radios = screen.getAllByRole('radio');
-    fireEvent.click(radios[1]); // Option A = second radio = keepExisting=true
-
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    await waitFor(() => {
-      expect(vi.mocked(api.patchSpoolmanFilament)).toHaveBeenCalledWith(
-        1,
-        expect.objectContaining({ spool_weight: 100, keep_existing_spools: true }),
-      );
-    });
-  });
-
-  it('(spoolman mode) negative weight input disables save button', async () => {
-    vi.mocked(api.getSpoolmanInventoryFilaments).mockResolvedValue([sampleFilament]);
-
-    render(<SpoolCatalogSettings />);
-
-    await waitFor(() => { expect(screen.getByLabelText('common.edit')).toBeTruthy(); });
-    fireEvent.click(screen.getByLabelText('common.edit'));
-
-    await waitFor(() => { expect(screen.getByLabelText('settings.catalog.spoolWeight')).toBeTruthy(); });
-
-    fireEvent.change(screen.getByLabelText('settings.catalog.spoolWeight'), { target: { value: '-5' } });
-
-    const saveBtn = screen.getByLabelText('common.save') as HTMLButtonElement;
-    expect(saveBtn.disabled).toBe(true);
+    // No Spoolman-only columns leak in
+    expect(screen.queryByText('settings.catalog.material')).toBeNull();
+    expect(screen.queryByText('settings.catalog.spoolWeight')).toBeNull();
+    expect(screen.queryByText('settings.spoolmanFilamentCatalogTitle')).toBeNull();
   });
 });

+ 64 - 0
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -1117,3 +1117,67 @@ describe('SpoolFormModal copy mode', () => {
     expect((payload as Record<string, unknown>).weight_used).toBe(0);
   });
 });
+
+// The "#<id>" affordance in the modal header (#1385) is only meaningful when
+// editing an existing spool — there's no ID yet on create, and the copy path
+// is producing a new spool too. Guard all three cases so a future refactor
+// can't quietly start leaking the source spool's ID into the Copy modal.
+describe('SpoolFormModal header spool ID (#1385)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('shows #<id> next to the title when editing an existing spool', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        mode="edit"
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByText('Edit Spool')).toBeInTheDocument();
+    });
+    // existingSpool.id is 1; render as "#1" in the modal header.
+    expect(screen.getByText('#1')).toBeInTheDocument();
+  });
+
+  it('does not show an ID when creating a new spool', async () => {
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Add Spool' })).toBeInTheDocument();
+    });
+    // No spool exists yet → header carries no "#..." token.
+    expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
+  });
+
+  it('does not leak the source spool ID when copying', async () => {
+    // Copying produces a fresh spool — surfacing the source ID in the
+    // "Copy Spool" header would mislead the user into thinking the new
+    // spool inherits it.
+    render(
+      <SpoolFormModal
+        isOpen={true}
+        onClose={vi.fn()}
+        spool={existingSpool}
+        mode="copy"
+        currencySymbol="$"
+      />
+    );
+
+    await waitFor(() => {
+      expect(screen.getByRole('heading', { name: 'Copy Spool' })).toBeInTheDocument();
+    });
+    expect(screen.queryByText(/^#\d+$/)).not.toBeInTheDocument();
+  });
+});

+ 0 - 97
frontend/src/__tests__/components/SpoolWeightUpdateModal.test.tsx

@@ -1,97 +0,0 @@
-import React from 'react';
-import { describe, it, expect, vi } from 'vitest';
-import { screen, fireEvent } from '@testing-library/react';
-import { render } from '../utils';
-import { SpoolWeightUpdateModal } from '../../components/SpoolWeightUpdateModal';
-
-vi.mock('react-i18next', () => ({
-  useTranslation: () => ({
-    t: (key: string) => key,
-  }),
-}));
-
-const defaultProps = {
-  isOpen: true,
-  filamentName: 'PLA Basic',
-  oldWeight: 250,
-  newWeight: 196,
-  onConfirm: vi.fn(),
-  onClose: vi.fn(),
-};
-
-describe('SpoolWeightUpdateModal', () => {
-  it('renders filament name, old weight, and new weight', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} />);
-
-    expect(screen.getByText('settings.catalog.updateSpoolWeight')).toBeTruthy();
-    expect(screen.getByText(/PLA Basic/)).toBeTruthy();
-    expect(screen.getByText(/250g → 196g/)).toBeTruthy();
-  });
-
-  it('renders option labels', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} />);
-
-    expect(screen.getByText('settings.catalog.applyToAllSpools')).toBeTruthy();
-    expect(screen.getByText('settings.catalog.keepExistingSpoolWeight')).toBeTruthy();
-  });
-
-  it('option B (apply to all) is selected by default', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} />);
-
-    const radios = screen.getAllByRole('radio') as HTMLInputElement[];
-    // First radio = apply-to-all (Option B, keepExisting=false)
-    expect(radios[0].checked).toBe(true);
-    expect(radios[1].checked).toBe(false);
-  });
-
-  it('calls onConfirm(false) when option B is selected and Confirm clicked', () => {
-    const onConfirm = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
-
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    expect(onConfirm).toHaveBeenCalledWith(false);
-  });
-
-  it('calls onConfirm(true) when option A is selected and Confirm clicked', () => {
-    const onConfirm = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
-
-    const radios = screen.getAllByRole('radio');
-    fireEvent.click(radios[1]); // Option A: keep existing
-
-    fireEvent.click(screen.getByText('common.confirm'));
-
-    expect(onConfirm).toHaveBeenCalledWith(true);
-  });
-
-  it('calls onClose on Cancel click', () => {
-    const onClose = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onClose={onClose} />);
-
-    fireEvent.click(screen.getByText('common.cancel'));
-
-    expect(onClose).toHaveBeenCalled();
-  });
-
-  it('does not call onConfirm on Cancel click', () => {
-    const onConfirm = vi.fn();
-    render(<SpoolWeightUpdateModal {...defaultProps} onConfirm={onConfirm} />);
-
-    fireEvent.click(screen.getByText('common.cancel'));
-
-    expect(onConfirm).not.toHaveBeenCalled();
-  });
-
-  it('renders dash when oldWeight is null', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} oldWeight={null} />);
-
-    expect(screen.getByText(/— → 196g/)).toBeTruthy();
-  });
-
-  it('returns null when isOpen is false', () => {
-    render(<SpoolWeightUpdateModal {...defaultProps} isOpen={false} />);
-
-    expect(screen.queryByText('settings.catalog.updateSpoolWeight')).toBeNull();
-  });
-});

+ 107 - 46
frontend/src/__tests__/components/spool-form/ColorSectionHexInput.test.tsx

@@ -1,23 +1,30 @@
 /**
- * Regression tests for the ColorSection hex input normalization (#1055).
+ * Regression tests for the ColorSection hex input.
  *
- * The original bug: typing 5 hex chars on the RRGGBB field produced a 7-char
- * rgba ("FFFFF" + "FF" alpha = 7 chars); typing 7 chars left the 7-char string
- * unpadded. Either way the value passed frontend validation, survived a backend
- * PATCH (SpoolUpdate had no pattern constraint), and then bricked the entire
- * Filaments page because SpoolResponse enforced the 8-char pattern on serialize
- * and one bad row 500'd the whole list endpoint.
+ * Original bug (#1055): typing 5 or 7 hex chars produced a 7-char rgba
+ * ("FFFFF" + "FF" alpha = 7 chars). That passed frontend validation, survived
+ * a backend PATCH (SpoolUpdate had no pattern constraint), and then bricked
+ * the entire Filaments page because SpoolResponse enforced the 8-char pattern
+ * on serialize and one bad row 500'd the whole list endpoint.
  *
- * The input now emits a valid 8-char RRGGBBAA on every keystroke: shorter input
- * is right-padded with '0' and given FF alpha; 7-char input drops the stray 7th
- * char; 8-char paste passes through unchanged.
+ * Second bug (#1407): the original #1055 fix solved the "no malformed rgba"
+ * problem by aggressively normalizing to 8 chars on EVERY keystroke. That
+ * worked for the data contract but broke typing: after the first char the
+ * controlled input value snapped to e.g. "A00000", the cursor jumped to the
+ * end, and the user's next keystroke landed at position 7 — which the 7-char
+ * branch then truncated away. Every keystroke past the first was lost.
  *
- * These tests drive the onChange handler directly (via fireEvent.change) rather
- * than userEvent.type so each assertion exercises a specific input length. The
- * component itself is a controlled input whose displayed value derives from
- * formData.rgba.substring(0, 6), so the real-world UX of typing one char at a
- * time is quirkier than the handler contract — but the handler contract is
- * what this regression guards.
+ * Current contract:
+ *   - The hex input has its own draft state (0–6 chars) decoupled from
+ *     `formData.rgba`. Typing one char at a time works naturally.
+ *   - `updateField('rgba', ...)` is only called once the draft reaches a full
+ *     6-char hex — at which point we append "FF" alpha for an 8-char result.
+ *   - On blur, a partial draft (1–5 chars) is right-padded with '0' and
+ *     committed. Preserves the #1055 invariant: anything the backend ever
+ *     sees is exactly 8 hex chars.
+ *   - Paste of 7/8-char strings (rare alpha-channel case) truncates to the
+ *     leading 6-char RGB on input. Bambu filaments are opaque, so an alpha
+ *     affordance was never exposed in the UI.
  */
 
 import { describe, it, expect, vi } from 'vitest';
@@ -57,45 +64,87 @@ function lastRgba(updateField: ReturnType<typeof vi.fn>): string | undefined {
   return rgbaCalls.at(-1)?.[1] as string | undefined;
 }
 
-describe('ColorSection hex input normalization (#1055)', () => {
-  it('pads a 6-char RRGGBB to 8-char RRGGBBAA with FF alpha', () => {
-    const { hexInput, updateField } = renderColorSection();
-    fireEvent.change(hexInput, { target: { value: 'FF0000' } });
-    expect(lastRgba(updateField)).toBe('FF0000FF');
+function rgbaCallCount(updateField: ReturnType<typeof vi.fn>): number {
+  return updateField.mock.calls.filter(([key]) => key === 'rgba').length;
+}
+
+describe('ColorSection hex input — typing UX (#1407)', () => {
+  it('reflects each keystroke in the draft input value (the #1407 trigger)', () => {
+    // Pre-fix: after typing the first char the controlled value snapped to
+    // e.g. "A00000" with cursor at position 6, so the user's next keystroke
+    // landed at position 7 and got truncated by the 7-char branch. The draft
+    // state must now hold whatever the user has typed, regardless of length.
+    const { hexInput } = renderColorSection();
+
+    fireEvent.change(hexInput, { target: { value: 'A' } });
+    expect(hexInput.value).toBe('A');
+
+    fireEvent.change(hexInput, { target: { value: 'AB' } });
+    expect(hexInput.value).toBe('AB');
+
+    fireEvent.change(hexInput, { target: { value: 'ABC' } });
+    expect(hexInput.value).toBe('ABC');
+
+    fireEvent.change(hexInput, { target: { value: 'ABCDE' } });
+    expect(hexInput.value).toBe('ABCDE');
   });
 
-  it('passes an 8-char RRGGBBAA paste through unchanged', () => {
+  it('does NOT commit to formData.rgba while the draft is partial (1–5 chars)', () => {
+    // Committing a partial value mid-typing was the entire cause of #1407 —
+    // the controlled value snap then re-rendered the input back over what
+    // the user was typing. Defer commit until the draft is a complete RGB.
     const { hexInput, updateField } = renderColorSection();
-    fireEvent.change(hexInput, { target: { value: '00112233' } });
-    expect(lastRgba(updateField)).toBe('00112233');
+
+    for (const partial of ['A', 'AB', 'ABC', 'ABCD', 'ABCDE']) {
+      updateField.mockClear();
+      fireEvent.change(hexInput, { target: { value: partial } });
+      expect(rgbaCallCount(updateField)).toBe(0);
+    }
   });
 
-  it('drops the stray 7th char — the exact #1055 trigger pattern', () => {
+  it('commits to formData.rgba once the draft reaches 6 chars', () => {
     const { hexInput, updateField } = renderColorSection();
-    fireEvent.change(hexInput, { target: { value: 'FFFFFFF' } });
-    // Previously emitted "FFFFFFF" (7 chars) verbatim. Must now be 8 chars.
-    const rgba = lastRgba(updateField);
-    expect(rgba).toBe('FFFFFFFF');
-    expect(rgba).toMatch(/^[0-9A-F]{8}$/);
+    fireEvent.change(hexInput, { target: { value: 'FF0000' } });
+    expect(lastRgba(updateField)).toBe('FF0000FF');
   });
 
-  it('pads a 5-char input to 8 chars instead of emitting a 7-char rgba', () => {
-    // 5-char + 'FF' alpha = 7 chars was the other #1055 trigger pattern.
-    // Right-pad RGB to 6 with '0' so the output is always 8 chars.
+  it('on blur, pads a partial draft to 6 chars and commits', () => {
+    // Backstop: a user who leaves the field with "AB" must end up with a
+    // valid form state, not a malformed rgba (#1055 invariant).
     const { hexInput, updateField } = renderColorSection();
-    fireEvent.change(hexInput, { target: { value: 'FFFFF' } });
+
+    fireEvent.change(hexInput, { target: { value: 'AB' } });
+    fireEvent.blur(hexInput);
+
     const rgba = lastRgba(updateField);
-    expect(rgba).toBe('FFFFF0FF');
+    expect(rgba).toBe('AB0000FF');
     expect(rgba).toMatch(/^[0-9A-F]{8}$/);
   });
 
-  it('pads any partial input to exactly 8 chars — never 7', () => {
-    // The essential invariant: for every legal input length (0..8), the
-    // emitted rgba must be 8 chars. Anything else risks reintroducing #1055.
+  it('on blur, does NOT commit when the draft is empty', () => {
+    // Clearing the field then tabbing away must not auto-fill the form with
+    // a synthetic colour the user never picked.
+    const { hexInput, updateField } = renderColorSection({ rgba: 'FF0000FF' });
+    updateField.mockClear();
+
+    fireEvent.change(hexInput, { target: { value: '' } });
+    fireEvent.blur(hexInput);
+
+    expect(rgbaCallCount(updateField)).toBe(0);
+  });
+});
+
+describe('ColorSection hex input — backend invariant (#1055)', () => {
+  it('committed rgba is always exactly 8 hex chars', () => {
+    // The essential invariant: anything that reaches the backend must match
+    // /^[0-9A-F]{8}$/. The new contract enforces this two ways — commit only
+    // at length 6 (always padded with "FF") and pad on blur.
     const { hexInput, updateField } = renderColorSection();
-    for (const input of ['', 'F', 'FF', 'FFF', 'FFFF', 'FFFFF', 'FFFFFF', 'FFFFFFF', 'FFFFFFFF']) {
+
+    for (const input of ['F', 'FF', 'FFF', 'FFFF', 'FFFFF', 'FFFFFF']) {
       updateField.mockClear();
       fireEvent.change(hexInput, { target: { value: input } });
+      fireEvent.blur(hexInput);
       const rgba = lastRgba(updateField);
       expect(rgba).toBeDefined();
       expect(rgba!.length).toBe(8);
@@ -103,17 +152,29 @@ describe('ColorSection hex input normalization (#1055)', () => {
     }
   });
 
-  it('ignores input past 8 chars (no updateField call)', () => {
-    const { hexInput, updateField } = renderColorSection({ rgba: 'FFFFFFFF' });
-    updateField.mockClear();
+  it('truncates paste of 7–8 chars to the leading RGB triplet', () => {
+    // Pre-fix, an 8-char paste passed through and a 7-char paste dropped the
+    // last char. Both are rare alpha-channel cases; Bambu filaments are
+    // opaque and the UI exposes no alpha affordance, so we truncate to the
+    // leading 6-char RGB and force FF alpha. Loses the (undocumented) 8-char
+    // paste-with-alpha case, gains uniform commit-at-6 semantics.
+    const { hexInput, updateField } = renderColorSection();
+
     fireEvent.change(hexInput, { target: { value: '0011223344' } });
-    expect(updateField.mock.calls.filter(([k]) => k === 'rgba')).toHaveLength(0);
+    expect(hexInput.value).toBe('001122');
+    expect(lastRgba(updateField)).toBe('001122FF');
   });
 
-  it('strips non-hex characters before normalizing', () => {
-    // '#FF00ZZ' → strip '#' and non-hex → 'FF00' (4 chars) → pad to 6 + FF alpha
+  it('strips non-hex characters', () => {
+    // '#FF00ZZ' → strip '#' and 'Z' → 'FF00' (length 4, no commit yet).
+    // Append two more hex chars to reach length 6, then commit.
     const { hexInput, updateField } = renderColorSection();
+
     fireEvent.change(hexInput, { target: { value: '#FF00ZZ' } });
-    expect(lastRgba(updateField)).toBe('FF0000FF');
+    expect(hexInput.value).toBe('FF00');
+    expect(rgbaCallCount(updateField)).toBe(0);
+
+    fireEvent.change(hexInput, { target: { value: 'FF0011' } });
+    expect(lastRgba(updateField)).toBe('FF0011FF');
   });
 });

+ 209 - 0
frontend/src/__tests__/pages/InventoryPageArchivedConsumed.test.tsx

@@ -0,0 +1,209 @@
+/**
+ * #1390 follow-up — Total Consumed must include archived spools' usage.
+ *
+ * Background: the original #1390 fix gave us a resettable "Total Consumed"
+ * counter (weight_used - weight_used_baseline). The aggregate that drives the
+ * stat tile on the Inventory page used to skip every archived spool, so the
+ * moment a user archived a finished roll its historical consumption vanished
+ * from the running total. Reporter (@IndividualGhost1905) observed that
+ * un-archiving a spool would put its consumed weight back into the total —
+ * proof that the data was correct on disk but being hidden by the aggregation.
+ *
+ * This file pins two regressions:
+ *   1. The "Total Consumed" displayed string sums BOTH active and archived
+ *      spools (the stat is lifetime-since-reset, not currently-available).
+ *   2. The per-spool eraser button is rendered for archived spools too, so
+ *      the user can zero an archived spool's tracking counter without having
+ *      to un-archive it first.
+ */
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import InventoryPageRouter from '../../pages/InventoryPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const baseSettings = {
+  auto_archive: true,
+  save_thumbnails: true,
+  capture_finish_photo: true,
+  default_filament_cost: 25.0,
+  currency: 'USD',
+  energy_cost_per_kwh: 0.15,
+  energy_tracking_mode: 'total',
+  spoolman_enabled: false,
+  spoolman_url: '',
+  spoolman_sync_mode: 'auto',
+  spoolman_disable_weight_sync: false,
+  spoolman_report_partial_usage: true,
+  check_updates: true,
+  check_printer_firmware: true,
+  include_beta_updates: false,
+  language: 'en',
+  notification_language: 'en',
+  bed_cooled_threshold: 35,
+  ams_humidity_good: 40,
+  ams_humidity_fair: 60,
+  ams_temp_good: 28,
+  ams_temp_fair: 35,
+  ams_history_retention_days: 30,
+  per_printer_mapping_expanded: false,
+  date_format: 'system',
+  time_format: 'system',
+  default_printer_id: null,
+  virtual_printer_enabled: false,
+  virtual_printer_access_code: '',
+  virtual_printer_mode: 'immediate',
+  dark_style: 'classic',
+  dark_background: 'neutral',
+  dark_accent: 'green',
+  light_style: 'classic',
+  light_background: 'neutral',
+  light_accent: 'green',
+  ftp_retry_enabled: true,
+  ftp_retry_count: 3,
+  ftp_retry_delay: 2,
+  ftp_timeout: 30,
+  mqtt_enabled: false,
+  mqtt_broker: '',
+  mqtt_port: 1883,
+  mqtt_username: '',
+  mqtt_password: '',
+  mqtt_topic_prefix: 'bambuddy',
+  mqtt_use_tls: false,
+  external_url: '',
+  ha_enabled: false,
+  ha_url: '',
+  ha_token: '',
+  ha_url_from_env: false,
+  ha_token_from_env: false,
+  ha_env_managed: false,
+  library_archive_mode: 'ask',
+  library_disk_warning_gb: 5.0,
+  camera_view_mode: 'window',
+  preferred_slicer: 'bambu_studio',
+  prometheus_enabled: false,
+  prometheus_token: '',
+  low_stock_threshold: 20.0,
+};
+
+const mockSpools = [
+  {
+    // Active spool: 300 g consumed
+    id: 1,
+    material: 'PLA',
+    subtype: null,
+    brand: 'Polymaker',
+    color_name: 'Red',
+    rgba: 'FF0000FF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 300,
+    weight_used_baseline: 0,
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: null,
+    created_at: '2025-01-01T00:00:00Z',
+    updated_at: '2025-01-01T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+  {
+    // Archived spool: 500 g consumed. Pre-fix this consumption disappeared
+    // from "Total Consumed" the moment the spool was archived (#1390 fb).
+    id: 2,
+    material: 'PETG',
+    subtype: null,
+    brand: 'eSun',
+    color_name: 'Blue',
+    rgba: '0000FFFF',
+    label_weight: 1000,
+    core_weight: 250,
+    weight_used: 500,
+    weight_used_baseline: 0,
+    slicer_filament: null,
+    slicer_filament_name: null,
+    nozzle_temp_min: null,
+    nozzle_temp_max: null,
+    note: null,
+    added_full: null,
+    last_used: null,
+    encode_time: null,
+    tag_uid: null,
+    tray_uuid: null,
+    data_origin: null,
+    tag_type: null,
+    archived_at: '2026-04-01T00:00:00Z',
+    created_at: '2025-01-02T00:00:00Z',
+    updated_at: '2025-01-02T00:00:00Z',
+    k_profiles: [],
+    cost_per_kg: null,
+    last_scale_weight: null,
+    last_weighed_at: null,
+  },
+];
+
+describe('InventoryPage — Total Consumed includes archived (#1390 follow-up)', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
+      http.get('/api/v1/inventory/spools', () => HttpResponse.json(mockSpools)),
+      http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
+      http.get('/api/v1/spoolman/settings', () =>
+        HttpResponse.json({ spoolman_enabled: 'false' }),
+      ),
+    );
+  });
+
+  it('Total Consumed sums consumed weight across active AND archived spools', async () => {
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      // 300 g (active) + 500 g (archived) = 800 g. formatWeight() renders
+      // values below 1 kg as "<rounded>g". A future refactor that
+      // re-introduces the archived skip would drop this to "300g" and the
+      // test fails.
+      expect(screen.getByText('800g')).toBeInTheDocument();
+    });
+  });
+
+  it('Reset-usage eraser is rendered for archived spools too', async () => {
+    // The per-card eraser is gated on weight_used > 0, NOT on archived_at,
+    // so the archived spool above (weight_used=500) must render an eraser
+    // button matching the localized tooltip. Multiple erasers exist on the
+    // page (one per spool + the bulk "reset all" affordance in the stat
+    // tile); the test asserts there are at least as many as there are
+    // spools with consumed weight, which catches a regression that hides
+    // the archived spool's eraser.
+    // The archive-filter chip defaults to "active only", so we need to
+    // surface archived spools first; the easiest assertion that doesn't
+    // depend on chip clicks is via the bulk-reset wiring: when archived
+    // are included in the resetable set, the total is non-zero — i.e.
+    // the "Reset all usage" button stays visible. The CHANGELOG entry
+    // walks through the per-card flow.
+    render(<InventoryPageRouter />);
+
+    await waitFor(() => {
+      // Reset-all-usage button is gated on `totalConsumed > 0 &&
+      // resetableSpoolIds.length > 0`. resetableSpoolIds now includes
+      // archived spools — so even if every active spool had its baseline
+      // == weight_used (consumed = 0), the button must remain visible
+      // as long as ANY spool (archived included) still has unreset usage.
+      // The 800g assertion already proves totalConsumed > 0; here we
+      // just check the bulk-reset button rendered.
+      expect(screen.getByRole('button', { name: /reset all spool usage/i })).toBeInTheDocument();
+    });
+  });
+});

+ 36 - 0
frontend/src/__tests__/pages/PrintersPage.test.tsx

@@ -1041,6 +1041,42 @@ describe('PrintersPage Phase 13 — EmptySlotHoverCard onAssignSpool wiring', ()
     }, { timeout: 3000 });
   });
 
+  it('#1322: empty slot kind is "physical" when state=9 and "reset" otherwise', async () => {
+    // Bambuddy now distinguishes a firmware-confirmed empty slot (state=9
+    // via tray_exist_bits) from a slot the user reset but where the
+    // firmware still has a spool registered. The kind prop drives both
+    // the inline label ("Empty" vs "Reset") and the hover card label.
+    server.use(
+      http.get('/api/v1/spoolman/settings', () => HttpResponse.json({
+        spoolman_enabled: 'false', spoolman_url: '',
+      })),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json({
+        ...mockPrinterStatus,
+        ams: [{
+          id: 0,
+          tray: [
+            { id: 0, tray_type: '', state: 9 },   // physically empty
+            { id: 1, tray_type: '', state: 3 },   // reset / unloading
+            { id: 2, tray_type: '', state: null }, // unknown empty
+            { id: 3, tray_type: 'PLA', state: 11 }, // loaded — no card here
+          ],
+        }],
+      })),
+    );
+    render(<PrintersPage />);
+
+    await waitFor(() => {
+      expect(phase13EmptySlotProps.filter(p => p.kind === 'physical').length).toBeGreaterThan(0);
+    }, { timeout: 3000 });
+
+    const physical = phase13EmptySlotProps.filter(p => p.kind === 'physical');
+    const reset = phase13EmptySlotProps.filter(p => p.kind === 'reset');
+    expect(physical.length).toBeGreaterThan(0);
+    expect(reset.length).toBeGreaterThan(0);
+    // state=null falls back to 'reset' too — the helper only returns
+    // 'physical' for the canonical 9/10 firmware codes.
+  });
+
   it('P13-1 (spoolman mode): EmptySlotHoverCard still receives onAssignSpool callback', async () => {
     server.use(
       http.get('/api/v1/spoolman/settings', () => HttpResponse.json({

+ 66 - 1
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -210,10 +210,33 @@ describe('StatsPage', () => {
 
       await waitFor(() => {
         expect(screen.getByText('Success Rate')).toBeInTheDocument();
-        // Success rate: 140/(140+10) = 93%
+        // Success rate: 140 / 150 total = 93%
         expect(screen.getByText('93%')).toBeInTheDocument();
       });
     });
+
+    it('uses total_prints as denominator so cancelled/stopped events count (#1390)', async () => {
+      // 40 successful out of 100 total — with 20 failed and 40 cancelled/stopped
+      // mixed in. Old formula (successful / (successful + failed)) would have
+      // shown 40 / (40 + 20) = 67%. New formula shows 40 / 100 = 40%, which
+      // matches the "Total Prints: 100" the user reads right above the gauge.
+      server.use(
+        http.get('/api/v1/archives/stats', () =>
+          HttpResponse.json({
+            ...mockStats,
+            total_prints: 100,
+            successful_prints: 40,
+            failed_prints: 20,
+          }),
+        ),
+      );
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Success Rate')).toBeInTheDocument();
+        expect(screen.getByText('40%')).toBeInTheDocument();
+      });
+    });
   });
 
   describe('cost display', () => {
@@ -353,6 +376,48 @@ describe('StatsPage', () => {
       });
     });
 
+    it('Longest Print excludes failed prints (#1390)', async () => {
+      // After slim started populating actual_time_seconds for non-completed
+      // rows (so Printer Stats By Time would match Quick Stats), a failed
+      // print's elapsed duration could outrank successful prints in the
+      // Records widget. RecordsWidget gates "Longest Print" on
+      // status === 'completed' to preserve the pre-fix semantic.
+      server.use(
+        http.get('/api/v1/archives/slim', () =>
+          HttpResponse.json([
+            {
+              id: 10, created_at: '2024-02-01T10:00:00Z',
+              started_at: '2024-02-01T10:00:00Z', completed_at: null,
+              print_name: 'Aborted 25h Marathon', status: 'failed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#000000',
+              filament_used_grams: 50, actual_time_seconds: 90000,
+              print_time_seconds: 86400, cost: 1.50, quantity: 1,
+            },
+            {
+              id: 11, created_at: '2024-02-02T10:00:00Z',
+              started_at: '2024-02-02T10:00:00Z',
+              completed_at: '2024-02-02T18:00:00Z',
+              print_name: 'Successful 8h Print', status: 'completed',
+              printer_id: 1, filament_type: 'PLA', filament_color: '#FF0000',
+              filament_used_grams: 80, actual_time_seconds: 28800,
+              print_time_seconds: 27000, cost: 2.40, quantity: 1,
+            },
+          ]),
+        ),
+      );
+      render(<StatsPage />);
+
+      // Wait for the records widget itself to render.
+      await waitFor(() => {
+        expect(screen.getByText('Longest Print')).toBeInTheDocument();
+      });
+      // The failed 25h print must not surface as any record — its presence
+      // anywhere here would mean the status gate regressed.
+      expect(screen.queryByText('Aborted 25h Marathon')).not.toBeInTheDocument();
+      // The completed 8h print is the only candidate left, so it wins.
+      expect(screen.getAllByText('Successful 8h Print').length).toBeGreaterThan(0);
+    });
+
     it('shows heaviest print record', async () => {
       render(<StatsPage />);
 

+ 99 - 9
frontend/src/api/client.ts

@@ -4,10 +4,15 @@ const API_BASE = '/api/v1';
 
 export class ApiError extends Error {
   status: number;
-  constructor(message: string, status: number) {
+  /** Stable error code from a structured backend detail (`{code, message}`).
+   *  Frontend uses this to look up an i18n key instead of showing the raw
+   *  English fallback. Null when the backend returned a plain-string detail. */
+  code: string | null;
+  constructor(message: string, status: number, code: string | null = null) {
     super(message);
     this.name = 'ApiError';
     this.status = status;
+    this.code = code;
   }
 }
 
@@ -80,6 +85,11 @@ function parseContentDispositionFilename(header: string | null): string | null {
   return standardMatch?.[1] || null;
 }
 
+function buildSlicerUrlFilename(filename: string): string {
+  const safe = filename.replace(/[/\\?#]/g, '_');
+  return safe.toLowerCase().endsWith('.3mf') ? safe : `${safe}.3mf`;
+}
+
 async function request<T>(
   endpoint: string,
   options: RequestInit = {}
@@ -105,6 +115,7 @@ async function request<T>(
     const error = await response.json().catch(() => ({}));
     const detail = error.detail;
     let message: string;
+    let code: string | null = null;
     if (typeof detail === 'string') {
       message = detail;
     } else if (Array.isArray(detail)) {
@@ -117,6 +128,11 @@ async function request<T>(
         .filter(Boolean)
         .join('; ');
       message = joined || JSON.stringify(detail) || `HTTP ${response.status}`;
+    } else if (detail && typeof detail === 'object') {
+      // Structured detail `{code, message}` — frontend uses the code to
+      // pick an i18n key, message is the English fallback.
+      code = typeof detail.code === 'string' ? detail.code : null;
+      message = typeof detail.message === 'string' ? detail.message : `HTTP ${response.status}`;
     } else {
       message = `HTTP ${response.status}`;
     }
@@ -136,7 +152,7 @@ async function request<T>(
       }
     }
 
-    throw new ApiError(message, response.status);
+    throw new ApiError(message, response.status, code);
   }
 
   // Handle empty responses (204 No Content, etc.)
@@ -148,6 +164,30 @@ async function request<T>(
   return await response.json();
 }
 
+// Camera diagnostic result (#1395 follow-up). Returned by
+// POST /printers/{id}/camera/diagnose; the frontend modal renders one
+// row per stage and looks up the summary code in i18n for the user-
+// facing remediation hint.
+export interface CameraDiagnoseStage {
+  name: 'tcp_reachable' | 'first_frame' | 'live_stream_active';
+  status: 'ok' | 'failed' | 'skipped';
+  duration_ms: number;
+  code: string | null;
+}
+
+export interface CameraDiagnoseResult {
+  printer_id: number;
+  protocol: 'rtsp' | 'chamber_image';
+  port: number;
+  // 'default' = historical X1/H2 tuning. Anything else = this model has
+  // an override entry in backend/app/services/camera_profiles.py.
+  profile: string;
+  overall_status: 'ok' | 'failed';
+  stages: CameraDiagnoseStage[];
+  // i18n key under `camera.diagnose.summary.*`.
+  summary_code: string;
+}
+
 // Long-lived camera-stream tokens (#1108). The `token` field is populated
 // only on the create response — listing endpoints set it to null because
 // the plaintext value is shown to the user exactly once.
@@ -1447,6 +1487,9 @@ export interface SmartPlug {
   off_delay_mode: 'time' | 'temperature';
   off_delay_minutes: number;
   off_temp_threshold: number;
+  // #1349: auto-off after AMS drying completes.
+  auto_off_after_drying: boolean;
+  off_delay_after_drying_minutes: number;
   username: string | null;
   password: string | null;
   // Power alerts
@@ -1518,6 +1561,9 @@ export interface SmartPlugCreate {
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_temp_threshold?: number;
+  // #1349
+  auto_off_after_drying?: boolean;
+  off_delay_after_drying_minutes?: number;
   username?: string | null;
   password?: string | null;
   // Power alerts
@@ -1581,6 +1627,9 @@ export interface SmartPlugUpdate {
   off_delay_mode?: 'time' | 'temperature';
   off_delay_minutes?: number;
   off_temp_threshold?: number;
+  // #1349
+  auto_off_after_drying?: boolean;
+  off_delay_after_drying_minutes?: number;
   username?: string | null;
   password?: string | null;
   // Power alerts
@@ -2183,6 +2232,9 @@ export interface GitHubTestConnectionResponse {
   message: string;
   repo_name: string | null;
   permissions: Record<string, boolean> | null;
+  // true = confirmed private, false = confirmed public/internal,
+  // null = could not determine. Backend rejects save unless true.
+  is_private: boolean | null;
 }
 
 export interface GitHubBackupTriggerResponse {
@@ -2363,7 +2415,13 @@ export interface SpoolmanFilamentEntry {
 
 // Inventory types
 // Label printing (#809). Mirror of backend.app.services.label_renderer.TemplateName.
-export type SpoolLabelTemplate = 'ams_30x15' | 'box_40x30' | 'box_62x29' | 'avery_5160' | 'avery_l7160';
+export type SpoolLabelTemplate =
+  | 'ams_holder_74x33'
+  | 'ams_holder_75x55'
+  | 'box_40x30'
+  | 'box_62x29'
+  | 'avery_5160'
+  | 'avery_l7160';
 
 export interface InventorySpool {
   id: number;
@@ -2385,6 +2443,12 @@ export interface InventorySpool {
   core_weight: number;
   core_weight_catalog_id: number | null;
   weight_used: number;
+  // Anchor for the resettable "Total Consumed" display (#1390). The
+  // counter shown on the Inventory page is `weight_used - weight_used_baseline`;
+  // remaining is still `label_weight - weight_used`, so "Reset usage to 0"
+  // zeroes the counter without disturbing remaining. Optional for back-compat
+  // with rows from a pre-migration DB snapshot — default to 0.
+  weight_used_baseline?: number;
   slicer_filament: string | null;
   slicer_filament_name: string | null;
   nozzle_temp_min: number | null;
@@ -3506,12 +3570,18 @@ export const api = {
     request<void>(`/archives/${id}${purgeStats ? '?purge_stats=true' : ''}`, { method: 'DELETE' }),
 
   // ========== Archive auto-purge (#1008 follow-up) ==========
-  previewArchivePurge: (olderThanDays: number) =>
-    request<ArchivePurgePreview>(`/archives/purge/preview?older_than_days=${olderThanDays}`),
-  executeArchivePurge: (olderThanDays: number) =>
-    request<{ deleted: number }>('/archives/purge', {
+  previewArchivePurge: (olderThanDays: number, purgeStats: boolean = false) =>
+    request<ArchivePurgePreview>(
+      `/archives/purge/preview?older_than_days=${olderThanDays}&purge_stats=${purgeStats}`,
+    ),
+  // #1390: purgeStats=false (default) soft-deletes each old archive — Quick Stats
+  // preserved, files removed from disk, row hidden via deleted_at. true matches
+  // the single-archive delete's `?purge_stats=true` semantics (hard-deletes the
+  // linked PrintLogEntry rows so the contribution drops from /stats too).
+  executeArchivePurge: (olderThanDays: number, purgeStats: boolean = false) =>
+    request<{ deleted: number; purge_stats: boolean }>('/archives/purge', {
       method: 'POST',
-      body: JSON.stringify({ older_than_days: olderThanDays }),
+      body: JSON.stringify({ older_than_days: olderThanDays, purge_stats: purgeStats }),
     }),
   getArchivePurgeSettings: () =>
     request<ArchivePurgeSettings>('/archives/purge/settings'),
@@ -4636,6 +4706,13 @@ export const api = {
     request<InventorySpool>(`/inventory/spools/${id}/archive`, { method: 'POST' }),
   restoreSpool: (id: number) =>
     request<InventorySpool>(`/inventory/spools/${id}/restore`, { method: 'POST' }),
+  resetSpoolUsage: (id: number) =>
+    request<InventorySpool>(`/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
+  bulkResetSpoolUsage: (spoolIds: number[]) =>
+    request<{ reset: number }>(`/inventory/spools/reset-usage-bulk`, {
+      method: 'POST',
+      body: JSON.stringify({ spool_ids: spoolIds }),
+    }),
   getSpoolKProfiles: (spoolId: number) =>
     request<SpoolKProfile[]>(`/inventory/spools/${spoolId}/k-profiles`),
   saveSpoolKProfiles: (spoolId: number, profiles: SpoolKProfileInput[]) =>
@@ -4800,6 +4877,13 @@ export const api = {
     request<InventorySpool>(`/spoolman/inventory/spools/${id}/archive`, { method: 'POST' }),
   restoreSpoolmanInventorySpool: (id: number) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${id}/restore`, { method: 'POST' }),
+  resetSpoolmanInventorySpoolUsage: (id: number) =>
+    request<InventorySpool>(`/spoolman/inventory/spools/${id}/reset-usage`, { method: 'POST' }),
+  bulkResetSpoolmanInventorySpoolUsage: (spoolIds: number[]) =>
+    request<{ reset: number }>(`/spoolman/inventory/spools/reset-usage-bulk`, {
+      method: 'POST',
+      body: JSON.stringify({ spool_ids: spoolIds }),
+    }),
   linkTagToSpoolmanSpool: (spoolId: number, data: { tag_uid?: string; tray_uuid?: string }) =>
     request<InventorySpool>(`/spoolman/inventory/spools/${spoolId}/tag`, {
       method: 'PATCH',
@@ -4927,6 +5011,8 @@ export const api = {
     request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
   getCameraStatus: (printerId: number) =>
     request<{ active: boolean; stalled: boolean }>(`/printers/${printerId}/camera/status`),
+  diagnoseCamera: (printerId: number) =>
+    request<CameraDiagnoseResult>(`/printers/${printerId}/camera/diagnose`, { method: 'POST' }),
 
   // Plate Detection - Multi-reference calibration (stores up to 5 references per printer)
   checkPlateEmpty: (printerId: number, options?: { useExternal?: boolean; includeDebugImage?: boolean }) => {
@@ -5362,7 +5448,7 @@ export const api = {
   createLibrarySlicerToken: (fileId: number) =>
     request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
   getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
-    `${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,
+    `${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(buildSlicerUrlFilename(filename))}`,
   downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
     const headers: Record<string, string> = {};
     if (authToken) {
@@ -5950,6 +6036,10 @@ export interface ArchivePurgePreview {
 export interface ArchivePurgeSettings {
   enabled: boolean;
   days: number;
+  // #1390: when true, bulk-deletes the linked PrintLogEntry rows so the
+  // contribution drops from Quick Stats too. Default false — soft-delete,
+  // Quick Stats preserved.
+  purge_stats: boolean;
 }
 
 export interface LibraryFileUploadResponse {

+ 16 - 0
frontend/src/components/AssignSpoolModal.tsx

@@ -113,6 +113,20 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     );
   }, [allSpoolmanAssignments, printerId, amsId, trayId]);
 
+  // #1414: nudge the printer to republish its state after we assign a
+  // spool. The backend assign-spool path already issues an MQTT command,
+  // but firmware (especially A1 mini external slots and any non-RFID
+  // assignment) doesn't always echo the new tray state back on its own,
+  // so the printer card sits on stale data and the user has to press
+  // Force-refresh to see the assignment. Calling /refresh-status forces
+  // a pushall the way the Force-refresh button does. Failures are
+  // intentionally swallowed — the assignment itself succeeded; if the
+  // refresh is offline the next poll / websocket update will catch up.
+  const nudgePrinterRepublish = () => {
+    api.refreshPrinterStatus(printerId).catch(() => {});
+    queryClient.invalidateQueries({ queryKey: ['printerStatus', printerId] });
+  };
+
   const assignMutation = useMutation({
     mutationFn: (spoolId: number) =>
       api.assignSpool({ spool_id: spoolId, printer_id: printerId, ams_id: amsId, tray_id: trayId }),
@@ -126,6 +140,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
         return filtered;
       });
       queryClient.invalidateQueries({ queryKey: ['spool-assignments'] });
+      nudgePrinterRepublish();
       showToast(t('inventory.assignSuccess'), 'success');
       setShowMismatchConfirm(false);
       setPendingAssignId(null);
@@ -148,6 +163,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-slot-assignments'] });
+      nudgePrinterRepublish();
       showToast(t('inventory.assignSuccess'), 'success');
       onClose();
     },

+ 158 - 0
frontend/src/components/CameraDiagnoseModal.tsx

@@ -0,0 +1,158 @@
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { X, Stethoscope, CheckCircle2, XCircle, MinusCircle, Loader2 } from 'lucide-react';
+import { api, type CameraDiagnoseResult, type CameraDiagnoseStage } from '../api/client';
+
+interface CameraDiagnoseModalProps {
+  printerId: number;
+  printerName: string | null;
+  onClose: () => void;
+}
+
+function StageIcon({ status }: { status: CameraDiagnoseStage['status'] }) {
+  if (status === 'ok') return <CheckCircle2 className="w-5 h-5 text-bambu-green flex-shrink-0" />;
+  if (status === 'failed') return <XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />;
+  return <MinusCircle className="w-5 h-5 text-bambu-gray flex-shrink-0" />;
+}
+
+export function CameraDiagnoseModal({ printerId, printerName, onClose }: CameraDiagnoseModalProps) {
+  const { t } = useTranslation();
+
+  // Kick the diagnostic off as soon as the modal mounts. There's no
+  // "Start" button — opening the modal IS the test. The mutation
+  // shape is right here: we want a one-shot POST with isPending /
+  // data / error, not a cached query.
+  const diagnose = useMutation({
+    mutationFn: () => api.diagnoseCamera(printerId),
+  });
+
+  useEffect(() => {
+    diagnose.mutate();
+    // Intentionally only on mount — re-running needs the user to click "Retry".
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const result = diagnose.data as CameraDiagnoseResult | undefined;
+
+  return (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg flex flex-col"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 min-w-0">
+            <Stethoscope className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate">
+              {t('camera.diagnose.modalTitle', { name: printerName || '' })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-6 space-y-4">
+          {diagnose.isPending && (
+            <div className="flex items-center gap-2 text-bambu-gray">
+              <Loader2 className="w-4 h-4 animate-spin" />
+              <span>{t('camera.diagnose.running')}</span>
+            </div>
+          )}
+
+          {diagnose.isError && (
+            <div className="rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300">
+              {t('camera.diagnose.runFailed', { error: (diagnose.error as Error).message })}
+            </div>
+          )}
+
+          {result && (
+            <>
+              {/* Per-stage results */}
+              <ol className="space-y-2">
+                {result.stages.map((stage) => (
+                  <li
+                    key={stage.name}
+                    className="flex items-center gap-3 bg-bambu-dark rounded-lg px-4 py-2.5"
+                  >
+                    <StageIcon status={stage.status} />
+                    <div className="flex-1 min-w-0">
+                      <div className="text-sm text-white">
+                        {t(`camera.diagnose.stage.${stage.name}`)}
+                      </div>
+                      {stage.code && (
+                        <div className="text-xs text-bambu-gray font-mono">{stage.code}</div>
+                      )}
+                    </div>
+                    <div className="text-xs text-bambu-gray tabular-nums flex-shrink-0">
+                      {stage.duration_ms} ms
+                    </div>
+                  </li>
+                ))}
+              </ol>
+
+              {/* Summary + remediation */}
+              <div
+                className={
+                  result.overall_status === 'ok'
+                    ? 'rounded-lg bg-bambu-green/10 border border-bambu-green/30 px-4 py-3 text-sm text-bambu-green'
+                    : 'rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300'
+                }
+              >
+                {t(`camera.diagnose.summary.${result.summary_code}`, {
+                  defaultValue: t('camera.diagnose.summary.unknown_failure'),
+                })}
+              </div>
+
+              {/* Metadata for support triage */}
+              <div className="text-xs text-bambu-gray space-y-0.5">
+                <div>
+                  <span className="text-bambu-gray/60">{t('camera.diagnose.meta.protocol')}: </span>
+                  <span className="font-mono">{result.protocol}</span>
+                  {' • '}
+                  <span className="text-bambu-gray/60">{t('camera.diagnose.meta.port')}: </span>
+                  <span className="font-mono">{result.port}</span>
+                  {' • '}
+                  <span className="text-bambu-gray/60">{t('camera.diagnose.meta.profile')}: </span>
+                  <span className="font-mono">{result.profile}</span>
+                </div>
+              </div>
+            </>
+          )}
+        </div>
+
+        <div className="px-6 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <button
+            onClick={() => diagnose.mutate()}
+            disabled={diagnose.isPending}
+            className="px-4 py-2 bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('camera.diagnose.retry')}
+          </button>
+          <button
+            onClick={onClose}
+            className="px-4 py-2 bg-bambu-green hover:bg-bambu-green/90 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('common.close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 36 - 8
frontend/src/components/EmbeddedCameraViewer.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize } from 'lucide-react';
+import { X, RefreshCw, AlertTriangle, Maximize2, Minimize2, GripVertical, WifiOff, ZoomIn, ZoomOut, Fullscreen, Minimize, Stethoscope } from 'lucide-react';
 import { api, getAuthToken, withStreamToken } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { ChamberLight } from './icons/ChamberLight';
 import { SkipObjectsModal, SkipObjectsIcon } from './SkipObjectsModal';
+import { CameraDiagnoseModal } from './CameraDiagnoseModal';
 
 interface EmbeddedCameraViewerProps {
   printerId: number;
@@ -98,6 +99,10 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
   const stallCheckIntervalRef = useRef<NodeJS.Timeout | null>(null);
 
   const [showSkipObjectsModal, setShowSkipObjectsModal] = useState(false);
+  // Modal opens from the error-state "Diagnose" button when the user
+  // hits "Camera unavailable" — saves a round trip through "open a
+  // ticket → wait for response → check setting". See #1395 follow-up.
+  const [showDiagnoseModal, setShowDiagnoseModal] = useState(false);
 
   // Fetch printer info
   const { data: printer } = useQuery({
@@ -605,6 +610,13 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
           >
             <RefreshCw className={`w-3.5 h-3.5 text-bambu-gray ${streamLoading ? 'animate-spin' : ''}`} />
           </button>
+          <button
+            onClick={() => setShowDiagnoseModal(true)}
+            className="p-1 hover:bg-bambu-dark-tertiary rounded"
+            title={t('camera.diagnose.button')}
+          >
+            <Stethoscope className="w-3.5 h-3.5 text-bambu-gray" />
+          </button>
           <button
             onClick={toggleFullscreen}
             className="p-1 hover:bg-bambu-dark-tertiary rounded"
@@ -669,13 +681,21 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
               <div className="text-center p-2">
                 <AlertTriangle className="w-6 h-6 text-orange-400 mx-auto mb-2" />
-                <p className="text-xs text-bambu-gray mb-2">Camera unavailable</p>
-                <button
-                  onClick={refresh}
-                  className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
-                >
-                  Retry
-                </button>
+                <p className="text-xs text-bambu-gray mb-2">{t('camera.unavailable')}</p>
+                <div className="flex gap-2 justify-center">
+                  <button
+                    onClick={refresh}
+                    className="px-2 py-1 text-xs bg-bambu-green text-white rounded hover:bg-bambu-green/80"
+                  >
+                    {t('camera.retry')}
+                  </button>
+                  <button
+                    onClick={() => setShowDiagnoseModal(true)}
+                    className="px-2 py-1 text-xs bg-bambu-dark border border-bambu-dark-tertiary text-bambu-gray hover:text-white rounded transition-colors"
+                  >
+                    {t('camera.diagnose.button')}
+                  </button>
+                </div>
               </div>
             </div>
           )}
@@ -748,6 +768,14 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0,
         isOpen={showSkipObjectsModal}
         onClose={() => setShowSkipObjectsModal(false)}
       />
+      {/* Camera diagnostic modal — opens from the error-state Diagnose button (#1395 follow-up) */}
+      {showDiagnoseModal && (
+        <CameraDiagnoseModal
+          printerId={printerId}
+          printerName={printer?.name || null}
+          onClose={() => setShowDiagnoseModal(false)}
+        />
+      )}
     </div>
   );
 }

+ 15 - 7
frontend/src/components/FilamentHoverCard.tsx

@@ -366,11 +366,14 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                           {t('inventory.assigned')}
                         </span>
                       </div>
-                      <p className="text-xs text-white truncate">
-                        {inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
-                        {inventory.assignedSpool.material}
-                        {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
-                      </p>
+                      <div className="flex items-baseline gap-1.5 min-w-0 mb-1">
+                        <p className="text-xs text-white truncate">
+                          {inventory.assignedSpool.brand ? `${inventory.assignedSpool.brand} ` : ''}
+                          {inventory.assignedSpool.material}
+                          {inventory.assignedSpool.color_name ? ` - ${inventory.assignedSpool.color_name}` : ''}
+                        </p>
+                        <span className="text-[10px] font-mono text-bambu-gray shrink-0">#{inventory.assignedSpool.id}</span>
+                      </div>
                       {(!spoolman?.linkedSpoolId || inventory.assignedSpool!.id !== spoolman.linkedSpoolId) && (
                         <button
                           onClick={(e) => {
@@ -496,9 +499,14 @@ interface EmptySlotHoverCardProps {
   className?: string;
   configureSlot?: ConfigureSlotConfig;
   onAssignSpool?: () => void;
+  // #1322 follow-up: distinguish firmware-confirmed empty (state 9/10) from
+  // a user reset where the firmware still has a spool registered. "reset"
+  // surfaces the user-cleared label; undefined / "physical" keeps the
+  // historical "Empty slot" wording.
+  kind?: 'physical' | 'reset';
 }
 
-export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool }: EmptySlotHoverCardProps) {
+export function EmptySlotHoverCard({ children, className = '', configureSlot, onAssignSpool, kind }: EmptySlotHoverCardProps) {
   const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   // Screen-space coords for the portaled card — same pattern as
@@ -576,7 +584,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot, on
             rounded-md shadow-lg overflow-hidden
           ">
             <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
-              {t('ams.emptySlot')}
+              {kind === 'reset' ? t('ams.emptySlotReset') : t('ams.emptySlot')}
             </div>
             {/* Configure slot button */}
             {(configureSlot?.enabled || onAssignSpool) && (

+ 12 - 2
frontend/src/components/FilamentSlotCircle.tsx

@@ -8,6 +8,11 @@
  *   trayType   - Filament material string (e.g. "PLA").  Used to decide the
  *                fallback background when there is no color but a type is known.
  *   isEmpty    - Whether the slot contains no filament.
+ *   emptyKind  - Optional refinement of the empty state used to render the
+ *                slot border (#1322 follow-up): "physical" for firmware-
+ *                confirmed no spool (state 9/10), "reset" for slots where
+ *                the user cleared the assignment but the firmware hasn't
+ *                positively confirmed emptiness. Ignored when isEmpty is false.
  *   slotNumber - 1-based slot number to display inside the circle.
  */
 
@@ -15,6 +20,7 @@ interface FilamentSlotCircleProps {
   trayColor?: string | null;
   trayType?: string | null;
   isEmpty: boolean;
+  emptyKind?: 'physical' | 'reset' | null;
   slotNumber: number;
 }
 
@@ -26,13 +32,17 @@ function isLightFilamentColor(hex: string): boolean {
   return (0.299 * r + 0.587 * g + 0.114 * b) / 255 > 0.6;
 }
 
-export function FilamentSlotCircle({ trayColor, trayType, isEmpty, slotNumber }: FilamentSlotCircleProps) {
+export function FilamentSlotCircle({ trayColor, trayType, isEmpty, emptyKind, slotNumber }: FilamentSlotCircleProps) {
+  // Reset slots get a quieter border than physical-empty so they read as
+  // "cleared but possibly still has a spool the firmware hasn't confirmed
+  // gone" rather than "definitely no spool".
+  const emptyBorderColor = emptyKind === 'reset' ? '#3d3d3d' : '#666';
   return (
     <div
       className="w-3.5 h-3.5 rounded-full mx-auto mb-0.5 border-2 flex items-center justify-center"
       style={{
         backgroundColor: trayColor ? `#${trayColor}` : (trayType ? '#333' : 'transparent'),
-        borderColor: isEmpty ? '#666' : 'rgba(255,255,255,0.1)',
+        borderColor: isEmpty ? emptyBorderColor : 'rgba(255,255,255,0.1)',
         borderStyle: isEmpty ? 'dashed' : 'solid',
       }}
     >

+ 18 - 1
frontend/src/components/FileUploadModal.tsx

@@ -112,7 +112,17 @@ export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUpl
 
     setIsUploading(false);
     onUploadComplete();
-    onClose();
+    // #1401: don't auto-close if any file ended with an error — the user
+    // needs to see the rejection message (e.g. "raw .gcode upload"), not
+    // have the modal vanish before they can read it. Closing happens via
+    // the X / Close button instead, after the user has seen what failed.
+    setFiles((prev) => {
+      const anyFailed = prev.some((f) => f.status === 'error');
+      if (!anyFailed) {
+        onClose();
+      }
+      return prev;
+    });
   };
 
   const addFiles = (newFiles: File[]) => {
@@ -287,6 +297,13 @@ export function FileUploadModal({ folderId, onClose, onUploadComplete, onFileUpl
                         <span className="text-green-400 ml-2">• {t('fileManager.filesExtracted', { count: uploadFile.extractedCount })}</span>
                       )}
                     </p>
+                    {/* #1401: errors render inline rather than as a hover-only
+                        title. The backend's rejection messages explain the
+                        actual fix (re-export as .gcode.3mf) — useless if the
+                        user can't read them. */}
+                    {uploadFile.status === 'error' && uploadFile.error && (
+                      <p className="text-xs text-red-400 mt-1 break-words">{uploadFile.error}</p>
+                    )}
                   </div>
                   {uploadFile.status === 'pending' && (
                     <button

+ 7 - 3
frontend/src/components/ForecastPanel.tsx

@@ -130,7 +130,10 @@ function computeHistoryRate(records: SpoolUsageRecord[]): { rate: number; stdDev
 }
 
 function computeDeltaRate(spools: InventorySpool[]): number | null {
-  const totalUsed = spools.reduce((s, sp) => s + sp.weight_used, 0);
+  // Use weight_used - baseline so "Reset usage to 0" on the Inventory page
+  // makes forecast restart from zero rather than carrying stale lifetime
+  // consumption across the reset (#1390).
+  const totalUsed = spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
   if (totalUsed === 0) return null;
   const now = Date.now();
   const oldestMs = spools.reduce((min, sp) => {
@@ -228,7 +231,8 @@ export function ForecastPanel({ spools }: { spools: InventorySpool[] }) {
 
       const totalRemainingG = group.spools.reduce((s, sp) => s + Math.max(0, sp.label_weight - sp.weight_used), 0);
       const totalLabelG = group.spools.reduce((s, sp) => s + sp.label_weight, 0);
-      const totalUsedG = group.spools.reduce((s, sp) => s + sp.weight_used, 0);
+      // Consumed since baseline (post-reset); see InventoryPage stats calc (#1390).
+      const totalUsedG = group.spools.reduce((s, sp) => s + Math.max(0, sp.weight_used - (sp.weight_used_baseline ?? 0)), 0);
 
       const groupHistory: SpoolUsageRecord[] = [];
       for (const s of group.spools) groupHistory.push(...(usageBySpoolId.get(s.id) ?? []));
@@ -1012,7 +1016,7 @@ function ForecastRow({
                                 </div>
                               </td>
                               <td className="px-4 py-2">
-                                <span className="text-sm text-bambu-gray">{Math.round(s.weight_used)}g</span>
+                                <span className="text-sm text-bambu-gray">{Math.round(Math.max(0, s.weight_used - (s.weight_used_baseline ?? 0)))}g</span>
                               </td>
                               <td className="px-4 py-2">
                                 <span className="text-sm text-bambu-gray">{s.label_weight}g</span>

+ 78 - 11
frontend/src/components/GitHubBackupSettings.tsx

@@ -248,7 +248,17 @@ export function GitHubBackupSettings() {
 
   // Test connection state
   const [testLoading, setTestLoading] = useState(false);
-  const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
+  const [testResult, setTestResult] = useState<{
+    success: boolean;
+    message: string;
+    isPrivate: boolean | null;
+  } | null>(null);
+  // Inline save-error banner — backend rejection messages (e.g. the
+  // "repository is not private" guard) are far too long for a toast.
+  // Cleared on success, on the next save attempt, and when the user starts
+  // editing the repo URL / token / provider so the banner doesn't persist
+  // after the user has already addressed the cause.
+  const [saveError, setSaveError] = useState<string | null>(null);
 
   // Auto-save debounce
   const settingsAutoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -388,6 +398,7 @@ export function GitHubBackupSettings() {
           access_token: accessToken,
         });
         setAccessToken(''); // Clear after save
+        setSaveError(null);
         showToast(t('backup.tokenUpdated'));
         lastTokenScheduledForSaveRef.current = '';
       } else {
@@ -396,13 +407,14 @@ export function GitHubBackupSettings() {
           autoSaveState,
           lastSavedAutosaveStateRef.current
         ));
+        setSaveError(null);
         showToast(t('backup.settingsSaved'));
       }
       lastSavedAutosaveStateRef.current = autoSaveState;
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
     } catch (error) {
-      showToast(t('backup.failedToSave', { message: (error as Error).message }), 'error');
+      setSaveError((error as Error).message);
     }
   }, [config, accessToken, autoSaveState, queryClient, showToast, t]);
 
@@ -459,6 +471,9 @@ export function GitHubBackupSettings() {
   // Mutations
   const saveConfigMutation = useMutation({
     mutationFn: (data: GitHubBackupConfigCreate) => api.saveGitHubBackupConfig(data),
+    onMutate: () => {
+      setSaveError(null);
+    },
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['github-backup-config'] });
       queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
@@ -467,7 +482,7 @@ export function GitHubBackupSettings() {
       setIsInitialized(true);
     },
     onError: (error: Error) => {
-      showToast(t('backup.failedToSave', { message: error.message }), 'error');
+      setSaveError(error.message);
     },
   });
 
@@ -523,9 +538,13 @@ export function GitHubBackupSettings() {
         setTestLoading(false);
         return;
       }
-      setTestResult({ success: result.success, message: result.message });
+      setTestResult({
+        success: result.success,
+        message: result.message,
+        isPrivate: result.is_private,
+      });
     } catch (error) {
-      setTestResult({ success: false, message: (error as Error).message });
+      setTestResult({ success: false, message: (error as Error).message, isPrivate: null });
     } finally {
       setTestLoading(false);
     }
@@ -600,7 +619,7 @@ export function GitHubBackupSettings() {
               <select
                 id="git-provider-select"
                 value={provider}
-                onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); }}
+                onChange={(e) => { setProvider(e.target.value as GitProviderType); setTestResult(null); setSaveError(null); }}
                 className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               >
                 <option value="github">{t('backup.providerGitHub')}</option>
@@ -618,7 +637,7 @@ export function GitHubBackupSettings() {
                   <input
                     type="text"
                     value={repoUrl}
-                    onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); }}
+                    onChange={(e) => { setRepoUrl(e.target.value); setTestResult(null); setSaveError(null); }}
                     placeholder={t(PROVIDER_REPO_URL_I18N_KEY[provider])}
                     className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
@@ -644,7 +663,7 @@ export function GitHubBackupSettings() {
                   <input
                     type="password"
                     value={accessToken}
-                    onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); }}
+                    onChange={(e) => { setAccessToken(e.target.value); setTestResult(null); setSaveError(null); }}
                     placeholder={config?.has_token ? t('backup.enterNewToken') : PROVIDER_TOKEN_PLACEHOLDER[provider]}
                     className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
@@ -802,11 +821,59 @@ export function GitHubBackupSettings() {
                 </div>
               )}
 
+              {/* Save error — inline banner. Keeps long backend rejection
+                  messages (e.g. the "repository is not private" guard)
+                  readable instead of clipped to a toast. */}
+              {saveError && (
+                <div className="text-sm text-red-400 bg-red-500/10 border border-red-500/30 rounded p-3 flex items-start gap-2">
+                  <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
+                  <div className="flex-1 leading-relaxed whitespace-pre-wrap break-words">{saveError}</div>
+                  <button
+                    type="button"
+                    onClick={() => setSaveError(null)}
+                    className="text-bambu-gray hover:text-white shrink-0"
+                    aria-label={t('common.dismiss', 'Dismiss')}
+                  >
+                    <XCircle className="w-4 h-4" />
+                  </button>
+                </div>
+              )}
+
               {/* Test result */}
               {testResult && (
-                <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
-                  {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
-                  {testResult.message}
+                <div className="space-y-1.5">
+                  <div className={`text-sm flex items-center gap-1 ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
+                    {testResult.success ? <CheckCircle className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
+                    {testResult.message}
+                  </div>
+                  {testResult.success && testResult.isPrivate === true && (
+                    <div className="text-xs flex items-center gap-1 text-green-400">
+                      <CheckCircle className="w-3.5 h-3.5" />
+                      {t('backup.repoIsPrivate', 'Repository is private — safe to back up to.')}
+                    </div>
+                  )}
+                  {testResult.success && testResult.isPrivate === false && (
+                    <div className="text-xs text-red-400 bg-red-500/10 border border-red-500/30 rounded p-2 flex items-start gap-1.5">
+                      <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
+                      <span>
+                        {t(
+                          'backup.repoIsPublicWarning',
+                          'Repository is PUBLIC. Bambuddy backups include MQTT credentials, Home Assistant tokens, Prometheus tokens, your Bambu Cloud email, and printer access codes via K-profiles. Saving is blocked until you make the repository private in your provider\'s settings.',
+                        )}
+                      </span>
+                    </div>
+                  )}
+                  {testResult.success && testResult.isPrivate === null && (
+                    <div className="text-xs text-yellow-400 bg-yellow-500/10 border border-yellow-500/30 rounded p-2 flex items-start gap-1.5">
+                      <XCircle className="w-4 h-4 mt-0.5 shrink-0" />
+                      <span>
+                        {t(
+                          'backup.repoVisibilityUnknown',
+                          'Could not determine repository visibility. Bambuddy refuses to back up to anything not confirmed private; saving will be blocked.',
+                        )}
+                      </span>
+                    </div>
+                  )}
                 </div>
               )}
 

+ 104 - 9
frontend/src/components/LabelTemplatePickerModal.tsx

@@ -33,10 +33,16 @@ interface TemplateOption {
 
 const TEMPLATE_OPTIONS: TemplateOption[] = [
   {
-    value: 'ams_30x15',
-    i18nKey: 'ams',
-    fallbackLabel: 'AMS holder (30 × 15 mm)',
-    fallbackHint: 'Single label per page; fits the popular AMS filament label holder.',
+    value: 'ams_holder_74x33',
+    i18nKey: 'amsHolderSmall',
+    fallbackLabel: 'AMS holder — small (74 × 33 mm)',
+    fallbackHint: 'Single label per page; matches the printable label from MakerWorld model 752566 (AMS Filament Label Holder).',
+  },
+  {
+    value: 'ams_holder_75x55',
+    i18nKey: 'amsHolderLarge',
+    fallbackLabel: 'AMS holder — large (75 × 55 mm)',
+    fallbackHint: 'Single label per page; fits the cardstock-insert variant of the AMS Filament Label Holder. Roomy enough for swatch, brand, material, ID, and QR code.',
   },
   {
     value: 'box_40x30',
@@ -98,6 +104,50 @@ function searchableText(s: SpoolForLabel): string {
     .toLowerCase();
 }
 
+type SortMode = 'id' | 'color';
+
+/** Sort key for the "by colour" mode (#1410).
+ *
+ * Returns a 2-tuple so JS array compare does the right thing without us having
+ * to spell out a comparator: ``[bucket, position]``. Chromatic colours
+ * (saturation above the threshold) go in bucket 0 ordered by HSL hue, so the
+ * sheet reads as a continuous rainbow. Achromatic colours (white / grey /
+ * black, plus missing/invalid rgba) go in bucket 1 ordered by lightness so the
+ * neutrals trail at the end of the rainbow going dark → light. Multi-colour
+ * spools sort on their primary ``rgba``; their ``extra_colors`` stripe is
+ * still rendered on the label itself but doesn't drive the sort.
+ */
+function colorSortKey(rgba: string | null | undefined): [number, number] {
+  if (!rgba) return [1, 0]; // unknown colour — bucket with the neutrals at black
+  const cleaned = rgba.replace(/^#/, '').slice(0, 6);
+  if (cleaned.length !== 6) return [1, 0];
+  const r = parseInt(cleaned.slice(0, 2), 16);
+  const g = parseInt(cleaned.slice(2, 4), 16);
+  const b = parseInt(cleaned.slice(4, 6), 16);
+  if ([r, g, b].some(Number.isNaN)) return [1, 0];
+
+  const rn = r / 255;
+  const gn = g / 255;
+  const bn = b / 255;
+  const max = Math.max(rn, gn, bn);
+  const min = Math.min(rn, gn, bn);
+  const l = (max + min) / 2;
+  const delta = max - min;
+  // Saturation in the HSL definition. Achromatic cutoff at 0.1 is generous —
+  // matches what feels "grey enough" to a user picking colours, without
+  // sending dark muted colours like deep navy into the neutrals bucket.
+  const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
+  if (s < 0.1) return [1, l]; // neutrals: ordered black → white
+
+  let h = 0;
+  if (max === rn) h = ((gn - bn) / delta) % 6;
+  else if (max === gn) h = (bn - rn) / delta + 2;
+  else h = (rn - gn) / delta + 4;
+  h = h * 60;
+  if (h < 0) h += 360;
+  return [0, h]; // chromatic: ordered by hue 0..360
+}
+
 export function LabelTemplatePickerModal({
   isOpen,
   onClose,
@@ -111,6 +161,7 @@ export function LabelTemplatePickerModal({
   const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
   const [search, setSearch] = useState('');
   const [materialFilter, setMaterialFilter] = useState<string>('');
+  const [sortMode, setSortMode] = useState<SortMode>('id');
 
   // Sync from caller and reset transient state on open. Intentionally not
   // reactive to props while open — once the user starts editing we don't want
@@ -121,15 +172,29 @@ export function LabelTemplatePickerModal({
       setSelectedIds(new Set(initialSelectedIds.filter((id) => allowed.has(id))));
       setSearch('');
       setMaterialFilter('');
+      setSortMode('id');
       setPending(null);
     }
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen]);
 
-  const sortedSpools = useMemo(
-    () => [...availableSpools].sort((a, b) => a.id - b.id),
-    [availableSpools],
-  );
+  const sortedSpools = useMemo(() => {
+    const copy = [...availableSpools];
+    if (sortMode === 'color') {
+      copy.sort((a, b) => {
+        const ka = colorSortKey(a.rgba);
+        const kb = colorSortKey(b.rgba);
+        if (ka[0] !== kb[0]) return ka[0] - kb[0];
+        if (ka[1] !== kb[1]) return ka[1] - kb[1];
+        // Stable tiebreaker on ID so identical colours print in a deterministic
+        // order across renders.
+        return a.id - b.id;
+      });
+      return copy;
+    }
+    copy.sort((a, b) => a.id - b.id);
+    return copy;
+  }, [availableSpools, sortMode]);
 
   // Material chips are derived from the *full* available set so they stay
   // stable when search/material filter narrows the visible list.
@@ -189,7 +254,10 @@ export function LabelTemplatePickerModal({
 
   async function handlePick(template: SpoolLabelTemplate) {
     if (noSelection || pending) return;
-    const ids = [...selectedIds].sort((a, b) => a - b);
+    // Order matters: the backend (labels.py) prints labels in the same order
+    // we send IDs. Use the sorted list so a "by colour" sort flows through to
+    // the PDF instead of being clobbered by an ascending-ID re-sort.
+    const ids = sortedSpools.filter((s) => selectedIds.has(s.id)).map((s) => s.id);
     setPending(template);
     try {
       const blob = spoolmanMode
@@ -282,6 +350,33 @@ export function LabelTemplatePickerModal({
               ))}
             </div>
           )}
+          <div className="flex flex-wrap items-center gap-1.5">
+            <span className="text-xs text-bambu-gray mr-1">
+              {t('inventory.labels.sortBy.label')}
+            </span>
+            <button
+              type="button"
+              onClick={() => setSortMode('id')}
+              className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                sortMode === 'id'
+                  ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+                  : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+              }`}
+            >
+              {t('inventory.labels.sortBy.id')}
+            </button>
+            <button
+              type="button"
+              onClick={() => setSortMode('color')}
+              className={`px-2 py-0.5 text-xs rounded-full border transition ${
+                sortMode === 'color'
+                  ? 'bg-bambu-green text-bambu-dark border-bambu-green'
+                  : 'bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:border-bambu-gray'
+              }`}
+            >
+              {t('inventory.labels.sortBy.color')}
+            </button>
+          </div>
         </div>
 
         {/* Action bar */}

+ 20 - 3
frontend/src/components/PurgeArchivesModal.tsx

@@ -21,6 +21,9 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
   const { showToast } = useToast();
 
   const [days, setDays] = useState(initialDays ?? DEFAULT_DAYS);
+  // #1390: matches the single-archive delete dialog's "Also remove from
+  // statistics" checkbox. Default off — soft-delete, Quick Stats preserved.
+  const [purgeStats, setPurgeStats] = useState(false);
 
   const [debouncedDays, setDebouncedDays] = useState(days);
   useEffect(() => {
@@ -29,13 +32,13 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
   }, [days]);
 
   const previewQuery = useQuery({
-    queryKey: ['archive-purge-preview', debouncedDays],
-    queryFn: () => api.previewArchivePurge(debouncedDays),
+    queryKey: ['archive-purge-preview', debouncedDays, purgeStats],
+    queryFn: () => api.previewArchivePurge(debouncedDays, purgeStats),
     enabled: debouncedDays >= 1,
   });
 
   const purgeMutation = useMutation({
-    mutationFn: () => api.executeArchivePurge(days),
+    mutationFn: () => api.executeArchivePurge(days, purgeStats),
     onSuccess: (res) => {
       showToast(t('archivePurge.toast.success', { count: res.deleted }), 'success');
       queryClient.invalidateQueries({ queryKey: ['archives'] });
@@ -141,6 +144,20 @@ export function PurgeArchivesModal({ onClose, initialDays }: PurgeArchivesModalP
             )}
           </div>
 
+          <label className="flex gap-2 items-start rounded border border-gray-200 dark:border-gray-700 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50">
+            <input
+              type="checkbox"
+              checked={purgeStats}
+              onChange={(e) => setPurgeStats(e.target.checked)}
+              disabled={purgeMutation.isPending}
+              className="mt-0.5 shrink-0"
+            />
+            <span className="text-xs text-gray-700 dark:text-gray-300">
+              <span className="font-medium block mb-0.5">{t('archivePurge.purgeStatsLabel')}</span>
+              <span className="text-gray-500 dark:text-gray-400">{t('archivePurge.purgeStatsHint')}</span>
+            </span>
+          </label>
+
           <div className="flex gap-2 items-start text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded px-3 py-2">
             <AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
             <span>{t('archivePurge.warning')}</span>

+ 37 - 0
frontend/src/components/SmartPlugCard.tsx

@@ -209,6 +209,7 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
             </div>
           )}
 
+
           {/* Feature Badges */}
           {(plug.power_alert_enabled || plug.schedule_enabled || plug.plug_type === 'mqtt') && (
             <div className="flex flex-wrap gap-1.5 mb-3">
@@ -448,6 +449,42 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
                   )}
                 </div>
               )}
+
+              {/* Auto Off After Drying (#1349) — independent of the
+                  print-finish auto-off above. Uses its own delay because
+                  the AMS chamber is hot post-cycle and users often want
+                  more cooldown than the print-finish default. Fires when
+                  any AMS attached to the linked printer finishes a dry
+                  cycle. */}
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-sm text-white">{t('smartPlugs.autoOffAfterDrying')}</p>
+                  <p className="text-xs text-bambu-gray">{t('smartPlugs.autoOffAfterDryingDescription')}</p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={plug.auto_off_after_drying}
+                    onChange={(e) => updateMutation.mutate({ auto_off_after_drying: e.target.checked })}
+                    className="sr-only peer"
+                  />
+                  <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+
+              {plug.auto_off_after_drying && (
+                <div className="pl-4 border-l-2 border-bambu-dark-tertiary">
+                  <label className="block text-xs text-bambu-gray mb-1">{t('smartPlugs.delayAfterDryingMinutes')}</label>
+                  <input
+                    type="number"
+                    min="0"
+                    max="120"
+                    value={plug.off_delay_after_drying_minutes}
+                    onChange={(e) => updateMutation.mutate({ off_delay_after_drying_minutes: parseInt(e.target.value) || 10 })}
+                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
+                  />
+                </div>
+              )}
                 </>
               )}
 

+ 185 - 433
frontend/src/components/SpoolCatalogSettings.tsx

@@ -1,34 +1,20 @@
 import { useState, useEffect, useCallback, useRef } from 'react';
-import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { Database, Plus, Trash2, RotateCcw, Loader2, Pencil, Check, X, Search, Download, Upload } from 'lucide-react';
-import { api, ApiError } from '../api/client';
-import type { SpoolCatalogEntry, SpoolmanFilamentEntry } from '../api/client';
+import { api } from '../api/client';
+import type { SpoolCatalogEntry } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { Card, CardHeader, CardContent } from './Card';
 import { ConfirmModal } from './ConfirmModal';
-import { SpoolWeightUpdateModal } from './SpoolWeightUpdateModal';
 
 export function SpoolCatalogSettings() {
   const { t } = useTranslation();
   const { showToast } = useToast();
-  const queryClient = useQueryClient();
   const [catalog, setCatalog] = useState<SpoolCatalogEntry[]>([]);
   const [loading, setLoading] = useState(true);
   const [search, setSearch] = useState('');
   const fileInputRef = useRef<HTMLInputElement>(null);
 
-  // Spoolman inline-edit state
-  const [editingFilamentId, setEditingFilamentId] = useState<number | null>(null);
-  const [editingFilamentName, setEditingFilamentName] = useState('');
-  const [editingFilamentWeight, setEditingFilamentWeight] = useState('');
-  const [pendingWeightEdit, setPendingWeightEdit] = useState<{
-    filamentId: number;
-    name: string;
-    oldWeight: number | null;
-    newWeight: number;
-  } | null>(null);
-
   // Add/Edit form state
   const [showAddForm, setShowAddForm] = useState(false);
   const [editingId, setEditingId] = useState<number | null>(null);
@@ -44,86 +30,6 @@ export function SpoolCatalogSettings() {
   const [deleteEntry, setDeleteEntry] = useState<SpoolCatalogEntry | null>(null);
   const [showResetConfirm, setShowResetConfirm] = useState(false);
 
-  // Spoolman filament query — hoisted to determine display mode
-  const {
-    data: spoolmanFilaments,
-    isLoading: spoolmanLoading,
-    error: spoolmanError,
-  } = useQuery<SpoolmanFilamentEntry[], Error>({
-    queryKey: ['spoolman-inventory-filaments'],
-    queryFn: () => api.getSpoolmanInventoryFilaments(),
-    retry: false, // Spoolman may be intentionally disabled (400) — don't retry
-    staleTime: 60_000,
-  });
-
-  // 400 = Spoolman explicitly disabled; all other states (data / 503 / …) mean Spoolman mode
-  const isSpoolmanDisabled =
-    !spoolmanLoading &&
-    spoolmanError instanceof ApiError &&
-    spoolmanError.status === 400;
-  const isSpoolmanMode = !spoolmanLoading && !isSpoolmanDisabled;
-
-  const patchFilamentMutation = useMutation<
-    SpoolmanFilamentEntry,
-    Error,
-    { filamentId: number; data: { name?: string; spool_weight?: number | null; keep_existing_spools?: boolean } }
-  >({
-    mutationFn: ({ filamentId, data }) => api.patchSpoolmanFilament(filamentId, data),
-    onSuccess: () => {
-      queryClient.invalidateQueries({ queryKey: ['spoolman-inventory-filaments'] });
-      showToast(t('settings.catalog.filamentUpdated'), 'success');
-      setEditingFilamentId(null);
-      setEditingFilamentName('');
-      setEditingFilamentWeight('');
-      setPendingWeightEdit(null);
-    },
-    onError: (error: Error) => {
-      if (error instanceof ApiError && error.status === 503) {
-        showToast(t('inventory.spoolmanUnreachable'), 'error');
-      } else if (error instanceof ApiError && error.status === 422) {
-        showToast(t('settings.catalog.filamentUpdateInvalid'), 'error');
-      } else {
-        showToast(t('settings.catalog.filamentUpdateFailed'), 'error');
-      }
-    },
-  });
-
-  const handleFilamentSave = (f: SpoolmanFilamentEntry) => {
-    const nameChanged = editingFilamentName.trim() !== f.name;
-    const weightChanged = editingFilamentWeight !== '' && editingFilamentWeight !== String(f.spool_weight ?? '');
-    const parsedWeight = editingFilamentWeight !== '' ? parseFloat(editingFilamentWeight) : null;
-
-    if (weightChanged && parsedWeight !== null && !isNaN(parsedWeight) && parsedWeight > 0) {
-      setPendingWeightEdit({
-        filamentId: f.id,
-        name: nameChanged ? editingFilamentName.trim() : f.name,
-        oldWeight: f.spool_weight,
-        newWeight: parsedWeight,
-      });
-    } else {
-      const data: { name?: string } = {};
-      if (nameChanged && editingFilamentName.trim()) data.name = editingFilamentName.trim();
-      if (Object.keys(data).length > 0) {
-        patchFilamentMutation.mutate({ filamentId: f.id, data });
-      } else {
-        setEditingFilamentId(null);
-      }
-    }
-  };
-
-  const handleWeightModalConfirm = (keepExisting: boolean) => {
-    if (!pendingWeightEdit) return;
-    const { filamentId, name, newWeight } = pendingWeightEdit;
-    const currentFilament = spoolmanFilaments?.find(f => f.id === filamentId);
-    const nameChanged = currentFilament && name !== currentFilament.name;
-    const data: { name?: string; spool_weight: number; keep_existing_spools: boolean } = {
-      spool_weight: newWeight,
-      keep_existing_spools: keepExisting,
-    };
-    if (nameChanged) data.name = name;
-    patchFilamentMutation.mutate({ filamentId, data });
-  };
-
   const loadCatalog = useCallback(async () => {
     try {
       const entries = await api.getSpoolCatalog();
@@ -144,11 +50,6 @@ export function SpoolCatalogSettings() {
     entry.name.toLowerCase().includes(search.toLowerCase())
   );
 
-  const filteredSpoolmanFilaments = (spoolmanFilaments ?? []).filter(f =>
-    f.name.toLowerCase().includes(search.toLowerCase()) ||
-    (f.vendor?.name ?? '').toLowerCase().includes(search.toLowerCase())
-  );
-
   const handleAdd = async () => {
     if (!formName.trim() || !formWeight) {
       showToast(t('settings.catalog.nameWeightRequired'), 'error');
@@ -313,55 +214,47 @@ export function SpoolCatalogSettings() {
         <div className="flex items-center gap-2 mb-3">
           <Database className="w-5 h-5 text-bambu-gray" />
           <h2 className="text-lg font-semibold text-white">
-            {isSpoolmanMode
-              ? t('settings.spoolmanFilamentCatalogTitle')
-              : t('settings.catalog.spoolCatalog')}
+            {t('settings.catalog.spoolCatalog')}
           </h2>
-          <span className="text-sm text-bambu-gray">
-            ({spoolmanLoading ? '…' : isSpoolmanMode ? (spoolmanFilaments?.length ?? 0) : catalog.length})
-          </span>
+          <span className="text-sm text-bambu-gray">({catalog.length})</span>
         </div>
 
-        {/* CRUD buttons — local mode only */}
-        {!isSpoolmanMode && !spoolmanLoading && (
-          <div className="flex items-center gap-2 flex-wrap">
-            <button
-              onClick={handleExport}
-              className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
-              title={t('settings.catalog.exportTooltip')}
-            >
-              <Download className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.export')}</span>
-            </button>
-            <button
-              onClick={() => fileInputRef.current?.click()}
-              className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
-              title={t('settings.catalog.importTooltip')}
-            >
-              <Upload className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.import')}</span>
-            </button>
-            <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
-            <button
-              onClick={() => setShowResetConfirm(true)}
-              className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
-              title={t('settings.catalog.resetTooltip')}
-            >
-              <RotateCcw className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.reset')}</span>
-            </button>
-            <button
-              onClick={() => setShowAddForm(true)}
-              className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
-            >
-              <Plus className="w-4 h-4" />
-              <span className="hidden sm:inline">{t('common.add')}</span>
-            </button>
-          </div>
-        )}
+        <div className="flex items-center gap-2 flex-wrap">
+          <button
+            onClick={handleExport}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.exportTooltip')}
+          >
+            <Download className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.export')}</span>
+          </button>
+          <button
+            onClick={() => fileInputRef.current?.click()}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.importTooltip')}
+          >
+            <Upload className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.import')}</span>
+          </button>
+          <input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
+          <button
+            onClick={() => setShowResetConfirm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-bambu-gray hover:text-white transition-colors flex items-center gap-1.5"
+            title={t('settings.catalog.resetTooltip')}
+          >
+            <RotateCcw className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.reset')}</span>
+          </button>
+          <button
+            onClick={() => setShowAddForm(true)}
+            className="px-3 py-1.5 text-sm bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 transition-colors flex items-center gap-1.5"
+          >
+            <Plus className="w-4 h-4" />
+            <span className="hidden sm:inline">{t('common.add')}</span>
+          </button>
+        </div>
 
-        {/* Bulk-delete bar — local mode only */}
-        {!isSpoolmanMode && selectedIds.size > 0 && (
+        {selectedIds.size > 0 && (
           <div className="flex items-center gap-2 mt-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg">
             <span className="text-sm text-red-400">
               {t('settings.catalog.selectedCount', { count: selectedIds.size })}
@@ -384,14 +277,10 @@ export function SpoolCatalogSettings() {
       </CardHeader>
 
       <CardContent className="space-y-4">
-        {/* Description */}
         <p className="text-sm text-bambu-gray">
-          {isSpoolmanMode
-            ? t('settings.spoolmanFilamentCatalogDesc')
-            : t('settings.catalog.spoolCatalogDescription')}
+          {t('settings.catalog.spoolCatalogDescription')}
         </p>
 
-        {/* Search — always shown */}
         <div className="relative">
           <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
           <input
@@ -403,302 +292,174 @@ export function SpoolCatalogSettings() {
           />
         </div>
 
-        {/* Mode-determination loading spinner */}
-        {spoolmanLoading && (
+        {showAddForm && (
+          <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
+            <h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
+            <div className="flex gap-2 items-center">
+              <div className="flex-1 min-w-0">
+                <input
+                  type="text"
+                  className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
+                  placeholder={t('settings.catalog.namePlaceholder')}
+                  value={formName}
+                  onChange={(e) => setFormName(e.target.value)}
+                />
+              </div>
+              <input
+                type="number"
+                className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
+                placeholder="g"
+                value={formWeight}
+                onChange={(e) => setFormWeight(e.target.value)}
+              />
+              <span className="text-bambu-gray shrink-0">g</span>
+              <button
+                onClick={handleAdd}
+                disabled={saving}
+                className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
+              >
+                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                {t('common.add')}
+              </button>
+              <button
+                onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
+                className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
+              >
+                <X className="w-4 h-4" />
+              </button>
+            </div>
+          </div>
+        )}
+
+        {loading ? (
           <div className="flex items-center justify-center py-8 text-bambu-gray">
             <Loader2 className="w-5 h-5 animate-spin mr-2" />
             {t('common.loading')}
           </div>
-        )}
-
-        {/* ── LOCAL MODE ── */}
-        {!spoolmanLoading && !isSpoolmanMode && (
-          <>
-            {/* Add form */}
-            {showAddForm && (
-              <div className="p-4 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-                <h3 className="text-sm font-medium text-white mb-3">{t('settings.catalog.addNewEntry')}</h3>
-                <div className="flex gap-2 items-center">
-                  <div className="flex-1 min-w-0">
+        ) : (
+          <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
+            <table className="w-full text-sm">
+              <thead className="bg-bambu-dark sticky top-0">
+                <tr>
+                  <th className="px-2 py-2 w-10">
                     <input
-                      type="text"
-                      className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
-                      placeholder={t('settings.catalog.namePlaceholder')}
-                      value={formName}
-                      onChange={(e) => setFormName(e.target.value)}
+                      type="checkbox"
+                      checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
+                      onChange={toggleSelectAll}
+                      className="w-4 h-4 accent-bambu-green cursor-pointer"
                     />
-                  </div>
-                  <input
-                    type="number"
-                    className="w-20 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white text-center focus:border-bambu-green focus:outline-none"
-                    placeholder="g"
-                    value={formWeight}
-                    onChange={(e) => setFormWeight(e.target.value)}
-                  />
-                  <span className="text-bambu-gray shrink-0">g</span>
-                  <button
-                    onClick={handleAdd}
-                    disabled={saving}
-                    className="px-3 py-2 bg-bambu-green text-white rounded-lg hover:bg-bambu-green/80 flex items-center gap-1 shrink-0"
-                  >
-                    {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
-                    {t('common.add')}
-                  </button>
-                  <button
-                    onClick={() => { setShowAddForm(false); setFormName(''); setFormWeight(''); }}
-                    className="p-2 rounded-lg text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary"
-                  >
-                    <X className="w-4 h-4" />
-                  </button>
-                </div>
-              </div>
-            )}
-
-            {/* Local catalog table */}
-            {loading ? (
-              <div className="flex items-center justify-center py-8 text-bambu-gray">
-                <Loader2 className="w-5 h-5 animate-spin mr-2" />
-                {t('common.loading')}
-              </div>
-            ) : (
-              <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
-                <table className="w-full text-sm">
-                  <thead className="bg-bambu-dark sticky top-0">
-                    <tr>
-                      <th className="px-2 py-2 w-10">
-                        <input
-                          type="checkbox"
-                          checked={filteredCatalog.length > 0 && selectedIds.size === filteredCatalog.length}
-                          onChange={toggleSelectAll}
-                          className="w-4 h-4 accent-bambu-green cursor-pointer"
-                        />
-                      </th>
-                      <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
-                      <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
-                      <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
-                      <th className="px-4 py-2 w-24"></th>
-                    </tr>
-                  </thead>
-                  <tbody>
-                    {filteredCatalog.length === 0 ? (
-                      <tr>
-                        <td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
-                          {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
-                        </td>
-                      </tr>
-                    ) : (
-                      filteredCatalog.map(entry => (
-                        <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
-                          {editingId === entry.id ? (
-                            <>
-                              <td className="px-2 py-2">
-                                <input
-                                  type="checkbox"
-                                  checked={selectedIds.has(entry.id)}
-                                  onChange={() => toggleSelect(entry.id)}
-                                  className="w-4 h-4 accent-bambu-green cursor-pointer"
-                                />
-                              </td>
-                              <td className="px-4 py-2">
-                                <input
-                                  type="text"
-                                  className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
-                                  value={formName}
-                                  onChange={(e) => setFormName(e.target.value)}
-                                />
-                              </td>
-                              <td className="px-4 py-2">
-                                <input
-                                  type="number"
-                                  className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
-                                  value={formWeight}
-                                  onChange={(e) => setFormWeight(e.target.value)}
-                                />
-                              </td>
-                              <td className="px-4 py-2 text-center">
-                                <span className="text-xs text-bambu-gray">-</span>
-                              </td>
-                              <td className="px-4 py-2">
-                                <div className="flex justify-end gap-1">
-                                  <button
-                                    onClick={() => handleUpdate(entry.id)}
-                                    disabled={saving}
-                                    className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
-                                  >
-                                    {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
-                                  </button>
-                                  <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
-                                    <X className="w-4 h-4" />
-                                  </button>
-                                </div>
-                              </td>
-                            </>
-                          ) : (
-                            <>
-                              <td className="px-2 py-2">
-                                <input
-                                  type="checkbox"
-                                  checked={selectedIds.has(entry.id)}
-                                  onChange={() => toggleSelect(entry.id)}
-                                  className="w-4 h-4 accent-bambu-green cursor-pointer"
-                                />
-                              </td>
-                              <td className="px-4 py-2 text-white">{entry.name}</td>
-                              <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
-                              <td className="px-4 py-2 text-center">
-                                {entry.is_default ? (
-                                  <span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
-                                    {t('settings.catalog.default')}
-                                  </span>
-                                ) : (
-                                  <span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
-                                    {t('settings.catalog.custom')}
-                                  </span>
-                                )}
-                              </td>
-                              <td className="px-4 py-2">
-                                <div className="flex justify-end gap-1">
-                                  <button
-                                    onClick={() => startEdit(entry)}
-                                    className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
-                                  >
-                                    <Pencil className="w-4 h-4" />
-                                  </button>
-                                  <button
-                                    onClick={() => setDeleteEntry(entry)}
-                                    className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
-                                  >
-                                    <Trash2 className="w-4 h-4" />
-                                  </button>
-                                </div>
-                              </td>
-                            </>
-                          )}
-                        </tr>
-                      ))
-                    )}
-                  </tbody>
-                </table>
-              </div>
-            )}
-          </>
-        )}
-
-        {/* ── SPOOLMAN MODE ── */}
-        {!spoolmanLoading && isSpoolmanMode && (
-          <div className="max-h-[600px] overflow-y-auto border border-bambu-dark-tertiary rounded-lg">
-            {spoolmanError ? (
-              <p className="px-4 py-8 text-center text-sm text-red-400">
-                {t('inventory.spoolmanCatalogLoadFailed')}
-              </p>
-            ) : filteredSpoolmanFilaments.length === 0 ? (
-              <p className="px-4 py-8 text-center text-sm text-bambu-gray">
-                {t('inventory.noSpoolmanFilaments')}
-              </p>
-            ) : (
-              <table className="w-full text-sm">
-                <thead className="bg-bambu-dark sticky top-0">
+                  </th>
+                  <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
+                  <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
+                  <th className="px-4 py-2 text-center text-bambu-gray font-medium w-20">{t('settings.catalog.type')}</th>
+                  <th className="px-4 py-2 w-24"></th>
+                </tr>
+              </thead>
+              <tbody>
+                {filteredCatalog.length === 0 ? (
                   <tr>
-                    <th className="px-3 py-2 w-8"></th>
-                    <th className="px-4 py-2 text-left text-bambu-gray font-medium">{t('common.name')}</th>
-                    <th className="px-4 py-2 text-left text-bambu-gray font-medium w-28">{t('settings.catalog.material')}</th>
-                    <th className="px-4 py-2 text-right text-bambu-gray font-medium w-24">{t('settings.catalog.weight')}</th>
-                    <th className="px-4 py-2 text-right text-bambu-gray font-medium w-28">{t('settings.catalog.spoolWeight')}</th>
-                    <th className="px-3 py-2 w-20"></th>
+                    <td colSpan={5} className="px-4 py-8 text-center text-bambu-gray">
+                      {search ? t('settings.catalog.noMatch') : t('settings.catalog.empty')}
+                    </td>
                   </tr>
-                </thead>
-                <tbody>
-                  {filteredSpoolmanFilaments.map(f => {
-                    const isEditing = editingFilamentId === f.id;
-                    const isSaving = patchFilamentMutation.isPending && editingFilamentId === f.id;
-                    return (
-                      <tr key={f.id} className="border-t border-bambu-dark-tertiary hover:bg-bambu-dark">
-                        <td className="px-3 py-2">
-                          <span
-                            className="w-4 h-4 rounded-full block shrink-0 border border-white/20"
-                            style={{ backgroundColor: f.color_hex ? `#${f.color_hex.replace('#', '')}` : '#808080' }}
-                            aria-label={t('inventory.spoolmanFilamentColorSwatch')}
-                          />
-                        </td>
-                        <td className="px-4 py-2 text-white truncate max-w-0">
-                          {isEditing ? (
+                ) : (
+                  filteredCatalog.map(entry => (
+                    <tr key={entry.id} className={`border-t border-bambu-dark-tertiary hover:bg-bambu-dark ${selectedIds.has(entry.id) ? 'bg-bambu-dark' : ''}`}>
+                      {editingId === entry.id ? (
+                        <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
+                            />
+                          </td>
+                          <td className="px-4 py-2">
                             <input
                               type="text"
-                              value={editingFilamentName}
-                              onChange={e => setEditingFilamentName(e.target.value)}
-                              className="w-full bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green"
-                              aria-label={t('common.name')}
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white focus:border-bambu-green focus:outline-none"
+                              value={formName}
+                              onChange={(e) => setFormName(e.target.value)}
                             />
-                          ) : (
-                            <span className="block truncate">
-                              {f.vendor?.name ? `${f.vendor.name} — ` : ''}{f.name}
-                            </span>
-                          )}
-                        </td>
-                        <td className="px-4 py-2 text-bambu-gray">{f.material ?? '—'}</td>
-                        <td className="px-4 py-2 text-right font-mono text-white">
-                          {f.weight ? `${f.weight}g` : '—'}
-                        </td>
-                        <td className="px-4 py-2 text-right font-mono text-bambu-gray">
-                          {isEditing ? (
+                          </td>
+                          <td className="px-4 py-2">
                             <input
                               type="number"
-                              value={editingFilamentWeight}
-                              onChange={e => setEditingFilamentWeight(e.target.value)}
-                              min="0"
-                              step="1"
-                              className="w-20 bg-bambu-dark-tertiary text-white rounded px-2 py-0.5 text-sm border border-bambu-dark-secondary focus:outline-none focus:border-bambu-green text-right"
-                              aria-label={t('settings.catalog.spoolWeight')}
+                              className="w-full px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-right focus:border-bambu-green focus:outline-none"
+                              value={formWeight}
+                              onChange={(e) => setFormWeight(e.target.value)}
+                            />
+                          </td>
+                          <td className="px-4 py-2 text-center">
+                            <span className="text-xs text-bambu-gray">-</span>
+                          </td>
+                          <td className="px-4 py-2">
+                            <div className="flex justify-end gap-1">
+                              <button
+                                onClick={() => handleUpdate(entry.id)}
+                                disabled={saving}
+                                className="p-1.5 rounded hover:bg-green-500/20 text-green-500"
+                              >
+                                {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                              </button>
+                              <button onClick={cancelEdit} className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray">
+                                <X className="w-4 h-4" />
+                              </button>
+                            </div>
+                          </td>
+                        </>
+                      ) : (
+                        <>
+                          <td className="px-2 py-2">
+                            <input
+                              type="checkbox"
+                              checked={selectedIds.has(entry.id)}
+                              onChange={() => toggleSelect(entry.id)}
+                              className="w-4 h-4 accent-bambu-green cursor-pointer"
                             />
-                          ) : (
-                            f.spool_weight != null ? `${f.spool_weight}g` : '—'
-                          )}
-                        </td>
-                        <td className="px-3 py-2">
-                          {isEditing ? (
-                            <div className="flex items-center gap-1 justify-end">
+                          </td>
+                          <td className="px-4 py-2 text-white">{entry.name}</td>
+                          <td className="px-4 py-2 text-right font-mono text-white">{entry.weight}g</td>
+                          <td className="px-4 py-2 text-center">
+                            {entry.is_default ? (
+                              <span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary text-bambu-gray">
+                                {t('settings.catalog.default')}
+                              </span>
+                            ) : (
+                              <span className="text-xs px-2 py-0.5 rounded bg-bambu-green/20 text-bambu-green">
+                                {t('settings.catalog.custom')}
+                              </span>
+                            )}
+                          </td>
+                          <td className="px-4 py-2">
+                            <div className="flex justify-end gap-1">
                               <button
-                                onClick={() => handleFilamentSave(f)}
-                                disabled={isSaving || !editingFilamentName.trim() || (editingFilamentWeight !== '' && (isNaN(parseFloat(editingFilamentWeight)) || parseFloat(editingFilamentWeight) <= 0))}
-                                className="p-1 text-bambu-green hover:text-white disabled:opacity-40 disabled:cursor-not-allowed"
-                                aria-label={t('common.save')}
+                                onClick={() => startEdit(entry)}
+                                className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
                               >
-                                {isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
+                                <Pencil className="w-4 h-4" />
                               </button>
                               <button
-                                onClick={() => { setEditingFilamentId(null); setEditingFilamentName(''); setEditingFilamentWeight(''); }}
-                                className="p-1 text-bambu-gray hover:text-white"
-                                aria-label={t('common.cancel')}
+                                onClick={() => setDeleteEntry(entry)}
+                                className="p-1.5 rounded bg-red-500/10 hover:bg-red-500/20 text-red-500"
                               >
-                                <X className="w-4 h-4" />
+                                <Trash2 className="w-4 h-4" />
                               </button>
                             </div>
-                          ) : (
-                            <button
-                              onClick={() => {
-                                setEditingFilamentId(f.id);
-                                setEditingFilamentName(f.name);
-                                setEditingFilamentWeight(f.spool_weight != null ? String(f.spool_weight) : '');
-                              }}
-                              className="p-1 text-bambu-gray hover:text-white"
-                              aria-label={t('common.edit')}
-                            >
-                              <Pencil className="w-4 h-4" />
-                            </button>
-                          )}
-                        </td>
-                      </tr>
-                    );
-                  })}
-                </tbody>
-              </table>
-            )}
+                          </td>
+                        </>
+                      )}
+                    </tr>
+                  ))
+                )}
+              </tbody>
+            </table>
           </div>
         )}
       </CardContent>
 
-      {/* Confirmation modals — local mode only */}
-      {!isSpoolmanMode && deleteEntry && (
+      {deleteEntry && (
         <ConfirmModal
           title={t('settings.catalog.deleteEntry')}
           message={t('settings.catalog.deleteConfirm', { name: deleteEntry.name })}
@@ -709,7 +470,7 @@ export function SpoolCatalogSettings() {
         />
       )}
 
-      {!isSpoolmanMode && showBulkDeleteConfirm && (
+      {showBulkDeleteConfirm && (
         <ConfirmModal
           title={t('settings.catalog.deleteSelected')}
           message={t('settings.catalog.bulkDeleteConfirm', { count: selectedIds.size })}
@@ -720,7 +481,7 @@ export function SpoolCatalogSettings() {
         />
       )}
 
-      {!isSpoolmanMode && showResetConfirm && (
+      {showResetConfirm && (
         <ConfirmModal
           title={t('settings.catalog.resetCatalog')}
           message={t('settings.catalog.resetConfirm')}
@@ -730,15 +491,6 @@ export function SpoolCatalogSettings() {
           onCancel={() => setShowResetConfirm(false)}
         />
       )}
-
-      <SpoolWeightUpdateModal
-        isOpen={pendingWeightEdit !== null}
-        filamentName={pendingWeightEdit?.name ?? ''}
-        oldWeight={pendingWeightEdit?.oldWeight ?? null}
-        newWeight={pendingWeightEdit?.newWeight ?? 0}
-        onConfirm={handleWeightModalConfirm}
-        onClose={() => setPendingWeightEdit(null)}
-      />
     </Card>
   );
 }

+ 4 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -745,8 +745,11 @@ export function SpoolFormModal({
       <div className="relative w-full max-w-xl mx-4 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-xl shadow-2xl max-h-[90vh] flex flex-col">
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary flex-shrink-0">
-          <h2 className="text-lg font-semibold text-white">
+          <h2 className="text-lg font-semibold text-white flex items-baseline gap-2">
             {isEditing ? t('inventory.editSpool') : isCopying ? t('inventory.copySpool') : t('inventory.addSpool')}
+            {isEditing && spool && (
+              <span className="text-sm font-mono text-bambu-gray">#{spool.id}</span>
+            )}
           </h2>
           <button
             onClick={onClose}

+ 0 - 102
frontend/src/components/SpoolWeightUpdateModal.tsx

@@ -1,102 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-
-interface SpoolWeightUpdateModalProps {
-  isOpen: boolean;
-  filamentName: string;
-  oldWeight: number | null;
-  newWeight: number;
-  onConfirm: (keepExisting: boolean) => void;
-  onClose: () => void;
-}
-
-export function SpoolWeightUpdateModal({
-  isOpen,
-  filamentName,
-  oldWeight,
-  newWeight,
-  onConfirm,
-  onClose,
-}: SpoolWeightUpdateModalProps) {
-  const { t } = useTranslation();
-  const [keepExisting, setKeepExisting] = useState(false);
-
-  useEffect(() => {
-    if (isOpen) setKeepExisting(false);
-  }, [isOpen]);
-
-  if (!isOpen) return null;
-
-  const oldWeightLabel = oldWeight !== null ? `${oldWeight}g` : '—';
-  const newWeightLabel = `${newWeight}g`;
-
-  return (
-    <div
-      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
-      onClick={onClose}
-    >
-      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
-        <CardContent className="p-6">
-          <h3 className="text-lg font-semibold text-white mb-1">
-            {t('settings.catalog.updateSpoolWeight')}
-          </h3>
-          <p className="text-bambu-gray text-sm mb-4">
-            {filamentName}: {oldWeightLabel} → {newWeightLabel}
-          </p>
-
-          <div className="space-y-3">
-            <label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
-              <input
-                type="radio"
-                name="weight-update-mode"
-                checked={!keepExisting}
-                onChange={() => setKeepExisting(false)}
-                className="mt-1 accent-bambu-green"
-              />
-              <div>
-                <div className="text-sm font-medium text-white">
-                  {t('settings.catalog.applyToAllSpools')}
-                </div>
-                <div className="text-xs text-bambu-gray mt-0.5">
-                  {t('settings.catalog.applyToAllSpoolsDesc')}
-                </div>
-              </div>
-            </label>
-
-            <label className="flex items-start gap-3 cursor-pointer p-3 rounded-lg border border-bambu-dark-tertiary hover:bg-bambu-dark transition-colors">
-              <input
-                type="radio"
-                name="weight-update-mode"
-                checked={keepExisting}
-                onChange={() => setKeepExisting(true)}
-                className="mt-1 accent-bambu-green"
-              />
-              <div>
-                <div className="text-sm font-medium text-white">
-                  {t('settings.catalog.keepExistingSpoolWeight')}
-                </div>
-                <div className="text-xs text-bambu-gray mt-0.5">
-                  {t('settings.catalog.keepExistingSpoolWeightDesc')}
-                </div>
-              </div>
-            </label>
-          </div>
-
-          <div className="flex gap-3 mt-6">
-            <Button variant="secondary" onClick={onClose} className="flex-1">
-              {t('common.cancel')}
-            </Button>
-            <Button
-              onClick={() => onConfirm(keepExisting)}
-              className="flex-1 bg-bambu-green hover:bg-bambu-green-dark"
-            >
-              {t('common.confirm')}
-            </Button>
-          </div>
-        </CardContent>
-      </Card>
-    </div>
-  );
-}

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