Procházet zdrojové kódy

Merge pull request #1843 from maziggy/0.2.4.8

**Bambuddy 0.2.4.8**

**⚠ Upgrade Notes — Read Before Updating**

0.2.4.8 is a fix-led patch release on the same 0.2.4 code base — no schema breaks beyond auto-migrated column additions (dialect-branched for SQLite and Postgres), no Docker entrypoint changes. The in-app Apply Update button in Settings → System → Updates works for Docker and for any native install already on 0.2.4.x.

Three behaviour-change callouts to know about before you upgrade:

- SSO autologin landed as a per-OIDC-provider opt-in (#1589, requested by @einstux). No existing install changes behaviour on upgrade — local login stays enabled by default and no provider is marked as autologin. To enable: turn on the new `is_autologin` flag on exactly one enabled OIDC provider in Settings → Auth, optionally disable local login at the same screen, and unauthenticated visitors will be redirected to your IdP on mount. A `BAMBUDDY_LOCAL_LOGIN=true` env-var bypasses both gates if your SSO provider is unreachable, and `/login?fallback=local` is a bookmarkable always-shows-the-form URL. Two refusal modes protect against lockout: disabling local login is rejected unless at least one OIDC provider is enabled AND the calling admin has a UserOIDCLink row.

- Sponsor-prompt thresholds lowered to fire for typical installs. The lowest print milestone drops from 100 → 10, archives from 50 → 5, and cost from 100 → 25 (EUR). Installs that already saw a sponsor toast within the last 14 days won't see more — the cross-family cooldown is unchanged. Installs that have never crossed the old 100-print bar become eligible the first time they pass 10 prints. The toast itself is the existing one, copy unchanged.

- Printer card UI refresh (#1661). Visual change on the main Printers page — re-arranged for structure and readability. The Filaments section header now carries the new AMS Filament Backup badge (blue circle-arrow when on, dim when off, "?" on A1 family where the cfg bit isn't yet parsed). External tray slots show `L` / `R` inside the colour circle instead of a separate `Ext-L` / `Ext-R` caption underneath, equalising the bottom row's height. No data-model change; existing customisation persists.

Make a backup before upgrading via Settings → Backup → Create Backup. Native install with update.sh snapshots the database automatically and rolls back on failure. Docker and fully-manual paths don't.

**Docker**

```
docker compose pull
docker compose up -d
```

docker-compose.yml doesn't need refreshing for 0.2.4.8.

**Native install — recommended path**

```
sudo BRANCH=main /opt/bambuddy/install/update.sh
```

Snapshots the database first and rolls back on failure.

**Native install — manual path**

```
sudo systemctl stop bambuddy
cd /opt/bambuddy
sudo -u bambuddy git fetch --prune --tags --force origin
sudo -u bambuddy git checkout main
sudo -u bambuddy git reset --hard origin/main
sudo /opt/bambuddy/venv/bin/pip install -r requirements.txt
cd frontend && sudo npm i
sudo systemctl start bambuddy
```

**Windows install**

Download `bambuddy-0.2.4.8-windows-x64-setup.exe` from this release page (or the unversioned `bambuddy-windows-x64-setup.exe` alias for an always-latest link). The In-app "Install Update" button on Windows installs now uses the same release-asset flow — no more "Could not find git executable" failures.

---
**Highlights**

0.2.4.8 is dominated by three threads: **AMS Filament Backup** becoming a first-class surface, **SSO autologin** for operators running their own OIDC, and a heavy contributor-credited fix sweep across notifications, the Virtual Printer, SpoolBuddy, and the queue.

The AMS Filament Backup thread closes the gap reporters @jpcast2001 and @Arn0uDz hit on dual-AMS X1C farms — when one slot ran low, the deficit check ignored a same-material backup peer and blocked the print. Bambuddy now reads and writes the per-printer backup state from the printer card (new badge in the Filaments section header), mirrors it into the colour-strict deficit check (#1762), and threads it into the dispatcher's "Prefer lowest" sort (#1766) so the spool that ought to run dry first really does. The mid-print spool-switch attribution bug @biduleman hit (#1771) — the firmware's end-of-print `total_layer_num=0` push was clobbering the cached total, collapsing every previous segment to 0g — is fixed in the same train.

SSO autologin (#1589) lands as a per-OIDC-provider opt-in with safety refusals on the disable-local-login toggle to prevent lockout, plus an env-var recovery path and a bookmarkable `/login?fallback=local`. See Upgrade Notes for the full surface.

Notifications got a sweep: false-positive "Print Stopped" on reprint after MQTT reconnect (#1807), finish-photo dropped on FINISH-state fallback (#1790), completion notification scoping the whole multi-plate project instead of the printed plate (#1785), and "Printer offline" push never firing on the disconnect edge (#1752) all fixed by separate contributors and reporters.

The Virtual Printer surface got another #1780 round driven by reporter @mkoreen — three commits bumping the slicer-MQTT race window to 5s with retroactive stamp, correcting a VP intake key mismatch that was silently dropping every slicer field, and forwarding the H2C rack-swap nozzle pick from slicer to dispatch.

Smaller-but-load-bearing: **per-printer Maintenance Mode** (#1476) so a printer can be excluded from the dispatcher without unplugging it, **per-filament humidity threshold for auto-drying** (#1605), **drag-reorder for grouped queue items**, and **heater history** (nozzle / bed / chamber) tracked with a per-tile chart-icon overlay opening the history modal.

---
**New Features**

- AMS Filament Backup status badge + toggle on the printer card; "Prefer lowest" actually picks the lowest spool (#1766, reported by @biduleman). Three states — ON (blue circle-arrow), OFF (dim), Unknown ("?" on A1 family where the cfg bit isn't parsed yet). Click toggles via the new `POST /printers/{id}/ams-backup` endpoint (gated on `printers:control`).

- Backup-aware filament deficit check, colour-strict (#1762, reported by @jpcast2001 + @Arn0uDz). Pre-print check sums grams across same-material backup peers instead of treating each slot in isolation.

- SSO autologin + disable local username/password login (#1589, requested by @einstux). Per-OIDC-provider `is_autologin` flag and `BAMBUDDY_LOCAL_LOGIN` env-var recovery path. See Upgrade Notes.

- Per-printer Maintenance Mode toggle (#1476, requested by @IndividualGhost1905 / Ferdi SEVER). Excludes a printer from queue dispatch without disconnecting it — useful for swap-out / cleaning / firmware-flash windows.

- Per-filament humidity threshold for auto-drying + alarms (#1605, requested by @thenewguy). Replaces the single global threshold with per-filament overrides; falls back to global when unset.

- Heater history (nozzle / bed / chamber) tracked + per-tile chart-icon overlay opens history modal. Mirrors the existing AMS sensor history surface; same retention + sampling cadence.

- Updated printer card UI for structure and readability (#1661). See Upgrade Notes for the visual change summary.

- Drag-reorder for grouped queue items; collapsed batches no longer block adjacent rows.

- Forecasting groups spools by colour + Forecast UI rework (#1814, by @Keybored02).

- "Auto-add unknown RFID spools" toggle + global confirmation modal (#1764). Reporters with multi-operator workshops saw duplicate inventory rows accumulating; toggle defaults to today's auto-add behaviour for compatibility, the modal asks before adding when unset.

- In-app sponsor-toast at earned milestones (Prints / Cost / Archives / Anniversary / Version-update). Cooldown 14 days across all families.

- Prominent sponsor banner on Settings → General. Single dismissable banner above the existing settings list on the default landing tab.

- Lower sponsor-prompt thresholds. See Upgrade Notes.

---
**Changes**

- Printer card AMS row: external tray height matches regular AMS slots. L/R label now lives inside the slot's colour circle in place of the index; bottom `Ext-L` / `Ext-R` caption removed.

---
**Fixed**

**Notifications**

- False-positive "Print Stopped" notification on reprint after MQTT reconnect (#1807, reported by @volodymyr-doba).

- Print-complete notification no longer drops the finish photo when the FINISH-state fallback fires (#1790, reported by @needo37). Producer→consumer sync.

- Completion notification scoped to printed plate on multi-plate 3MFs (#1785). Multi-plate single-plate prints used to report the whole project's duration + material; now matches the queue card.

- Push notification for "Printer offline" actually fires (#1752, reported by @saint-hh). `on_printer_offline` dispatch wired on the disconnect edge — the callback existed but the wire was missing.

**Auth / OIDC / permissions**

- API keys with Manage Library permission can rename / delete / move library files (#1832, reporter @MorganMLGman). `LIBRARY_UPDATE_ALL` / `LIBRARY_DELETE_ALL` now map to `can_manage_library`; `LIBRARY_PURGE` stays admin-only.

- Sidebar entries for Files / Archives / Queue no longer hidden from non-admin users with granular `*_read` access (#1755, reported by @knifesk).

- Auth preserves the original URL across login + OIDC round-trip (#1750). Bookmarks land where the user clicked, not the dashboard.

- Printer secrets restricted to update-authority callers. Tightens which API surfaces can return access codes and credentials.

**Virtual printer / dispatch**

- H2C nozzle pick from Bambu Studio preserved on dual-nozzle rack variant + VP slicer-field intake (#1780, reported by @mkoreen). Three commits landed: slicer-MQTT race window bumped to 5s with retroactive stamp, VP intake key mismatch corrected, rack-swap nozzle pick forwarded to dispatch.

- Mid-print AMS Backup spool-switch correctly splits weight instead of crediting all to the second spool (#1771, reported by @biduleman). Cascades `state.total_layers` → `last_layer_num` → equal-split fence; firmware's end-of-print `total_layer_num=0` push no longer clobbers the cached total.

**Print queue + scheduler**

- `require_previous_success` no longer permanently blocks a printer's queue after a failure (#1818, reported by @jmassardo). Resume-after-failure clears the gate.

**Inventory / AMS / SpoolBuddy**

- Assign-spool picker note now visible on mobile (#793 follow-up, reporter @EmcetPL). Note rendered as a muted line under the weight on both internal and Spoolman branches; `title=` tooltip preserved for desktop hover.

- SpoolBuddy "Assign to AMS" preserves the user's slicer preset instead of pushing Generic (#1815, reported by @Bgabor997). Resolver now preserves PFUS / PFCN `setting_id`.

- SpoolBuddy inventory search matches spool ID, slicer filament name, and storage location (#1738, reported by @shaddowlink). Was filtering on spool name only.

- H2S active-tray highlight no longer stuck on AMS slot 1 during external-spool prints (#1822, reported by @ojimpo). `tray_now` promoted to 254 on all-external prints.

- Unknown-tag modal no longer pops for slots with no RFID.

**Connection / install**

- Connection diagnostic no longer reports false camera-port warning on A1 / A1 Mini / P1 (#1799 closing #1798, by @lesbass / Stefano Maffeis).

- Docker installer escalates on EACCES instead of failing on `/opt/bambuddy` (#1774, reported by @jmoore-skild). Auto-sudo on the directory create when needed.

- In-app "Install Update" on Windows installer switched to release-asset update flow — no more "Could not find git executable" failures.

**UI / rendering**

- Chamber-fan badge hidden on open-frame Bambu printers that have no chamber fan.

- Archive thumbnails rendered server-side when the sidecar slice skips them (#1759, reported by @VID-PRO).

- Local Presets page: deleted row optimistically removed instead of staying visible until refetch returned (which had allowed a second delete click → 404).

- AMS history modal respects theme background variant in stats modal.

- Post-#1661 printer-card cleanup — test fixtures + hover-card fly-in removal.

**Docs / archives**

- Archives "Step 4" docs link no longer 404s (#1812, reported by @Spanholz). Corrected `bambuddy.cool/wiki/...` host to `wiki.bambuddy.cool/...`.

- Archives backfill NULL `created_at` + tolerate NULL in response (#1732). Old archive rows from upgrades that ran before the column existed now render correctly.

---
**Security**

- dompurify 3.4.10 → 3.4.11 (GHSA-cmwh-pvxp-8882, moderate). Frontend HTML sanitisation library; no Bambuddy code change.

- Backend dependency security floor bumps — cryptography, python-multipart, starlette. Floor raises so fresh installs and CI pick up the fixed runtime.

- Floor pins for pydantic-settings ≥2.14.2 + msgpack ≥1.2.1 to clear `pip-audit`.

- Vite 7 → 8 + plugin-react 5.2 (major bump) + frontend dependency bumps.

- 422 constant rename — pinpoints the dependency-related security response constant.

---
**Contributors**

External code contributors with merged PRs in this release: @EdwardChamberlain (#1661 — Update printer card UI for structure and readability), @Keybored02 (#1814 — Forecasting: group spools by colour + UI rework), @lesbass / Stefano Maffeis (#1799 closing #1798 — A1 / A1 Mini / P1 camera-port diagnostic). Thank you!

The reporters who drove the fixes in this release are credited inline next to each Fixed entry above.

---
**Sponsors**

Bambuddy is sustainable thanks to people who put their money where their use is. If this release saved you time or kept your farm running, the project runs on recurring contributions — there's no paid tier, no telemetry, no upsell, just sustainable maintenance.

- **GitHub Sponsors** (recurring, 5 tiers from $5/mo to $300/mo) — https://github.com/sponsors/maziggy
- **Ko-fi** (one-time or recurring) — https://ko-fi.com/maziggy

Everyone supporting Bambuddy is named at https://bambuddy.cool/backers.html (and in `BACKERS.md` in the repo). Special thanks to @northpole3dprinting (Corporate tier) and all the Patron / Supporter / Backer sponsors who made this release possible.
MartinNYHC před 2 měsíci
rodič
revize
a83dbcfa51
100 změnil soubory, kde provedl 20623 přidání a 2111 odebrání
  1. 11 0
      .env.example
  2. 55 0
      CHANGELOG.md
  3. 5 1
      CONTRIBUTING.md
  4. 2 0
      backend/app/api/routes/_spoolman_helpers.py
  5. 15 87
      backend/app/api/routes/archives.py
  6. 92 6
      backend/app/api/routes/auth.py
  7. 0 32
      backend/app/api/routes/background_dispatch.py
  8. 25 5
      backend/app/api/routes/camera.py
  9. 479 2
      backend/app/api/routes/inventory.py
  10. 203 121
      backend/app/api/routes/library.py
  11. 300 0
      backend/app/api/routes/library_tags.py
  12. 25 6
      backend/app/api/routes/makerworld.py
  13. 24 8
      backend/app/api/routes/mfa.py
  14. 2 0
      backend/app/api/routes/notifications.py
  15. 333 69
      backend/app/api/routes/print_queue.py
  16. 140 0
      backend/app/api/routes/printer_sensor_history.py
  17. 420 11
      backend/app/api/routes/printers.py
  18. 57 7
      backend/app/api/routes/settings.py
  19. 55 0
      backend/app/api/routes/sponsor_prompt.py
  20. 146 0
      backend/app/api/routes/spoolman.py
  21. 190 7
      backend/app/api/routes/spoolman_inventory.py
  22. 28 0
      backend/app/api/routes/system.py
  23. 64 0
      backend/app/api/routes/updates.py
  24. 6 0
      backend/app/api/routes/virtual_printers.py
  25. 12 11
      backend/app/api/routes/websocket.py
  26. 52 6
      backend/app/core/auth.py
  27. 1 1
      backend/app/core/config.py
  28. 367 8
      backend/app/core/database.py
  29. 102 0
      backend/app/core/local_config.py
  30. 9 5
      backend/app/core/permissions.py
  31. 9009 0
      backend/app/data/hms_actions.json
  32. 576 42
      backend/app/main.py
  33. 6 0
      backend/app/models/__init__.py
  34. 12 1
      backend/app/models/active_print_spoolman.py
  35. 4 1
      backend/app/models/filament_sku_settings.py
  36. 48 0
      backend/app/models/library.py
  37. 27 0
      backend/app/models/location.py
  38. 1 0
      backend/app/models/notification.py
  39. 15 5
      backend/app/models/notification_template.py
  40. 7 0
      backend/app/models/oidc_provider.py
  41. 25 0
      backend/app/models/print_queue.py
  42. 4 0
      backend/app/models/printer.py
  43. 40 0
      backend/app/models/printer_sensor_history.py
  44. 1 0
      backend/app/models/shopping_list.py
  45. 39 0
      backend/app/models/sponsor_toast_state.py
  46. 4 1
      backend/app/models/spool.py
  47. 5 0
      backend/app/models/virtual_printer.py
  48. 3 25
      backend/app/schemas/archive.py
  49. 3 0
      backend/app/schemas/auth.py
  50. 85 26
      backend/app/schemas/library.py
  51. 39 0
      backend/app/schemas/location.py
  52. 5 0
      backend/app/schemas/notification.py
  53. 64 1
      backend/app/schemas/print_queue.py
  54. 50 3
      backend/app/schemas/printer.py
  55. 127 0
      backend/app/schemas/settings.py
  56. 23 0
      backend/app/schemas/sponsor_prompt.py
  57. 2 0
      backend/app/schemas/spool.py
  58. 0 1100
      backend/app/services/background_dispatch.py
  59. 466 61
      backend/app/services/bambu_mqtt.py
  60. 14 0
      backend/app/services/camera_fanout.py
  61. 281 9
      backend/app/services/filament_deficit.py
  62. 75 0
      backend/app/services/hms_actions.py
  63. 354 0
      backend/app/services/location_service.py
  64. 110 8
      backend/app/services/notification_service.py
  65. 10 7
      backend/app/services/obico_actions.py
  66. 231 0
      backend/app/services/plate_thumbnail.py
  67. 268 43
      backend/app/services/print_scheduler.py
  68. 26 4
      backend/app/services/printer_diagnostic.py
  69. 170 5
      backend/app/services/printer_manager.py
  70. 3 4
      backend/app/services/slice_dispatch.py
  71. 14 1
      backend/app/services/slicer_filament_resolver.py
  72. 256 0
      backend/app/services/sponsor_prompt.py
  73. 93 1
      backend/app/services/spoolman.py
  74. 297 57
      backend/app/services/spoolman_tracking.py
  75. 19 6
      backend/app/services/usage_tracker.py
  76. 253 9
      backend/app/services/virtual_printer/manager.py
  77. 8 0
      backend/app/services/virtual_printer/mqtt_server.py
  78. 108 4
      backend/app/utils/threemf_tools.py
  79. 15 0
      backend/tests/conftest.py
  80. 19 0
      backend/tests/integration/test_auth_api.py
  81. 10 2
      backend/tests/integration/test_auth_apikey_rbac.py
  82. 19 289
      backend/tests/integration/test_background_dispatch_api.py
  83. 33 0
      backend/tests/integration/test_camera_api.py
  84. 212 0
      backend/tests/integration/test_inventory_bulk.py
  85. 69 0
      backend/tests/integration/test_inventory_from_slot_no_tag.py
  86. 322 0
      backend/tests/integration/test_library_api.py
  87. 288 0
      backend/tests/integration/test_library_tags_api.py
  88. 204 0
      backend/tests/integration/test_local_login_gate.py
  89. 174 0
      backend/tests/integration/test_locations_api.py
  90. 302 0
      backend/tests/integration/test_makerworld_apikey_auth.py
  91. 251 3
      backend/tests/integration/test_ownership_permissions.py
  92. 706 0
      backend/tests/integration/test_print_queue_api.py
  93. 824 0
      backend/tests/integration/test_printers_api.py
  94. 229 0
      backend/tests/integration/test_session_policy.py
  95. 5 0
      backend/tests/integration/test_settings_ui_preferences.py
  96. 48 0
      backend/tests/integration/test_sponsor_prompt_api.py
  97. 188 0
      backend/tests/integration/test_spool_by_tag_lookup.py
  98. 4 0
      backend/tests/integration/test_spoolman_inventory_api.py
  99. 200 0
      backend/tests/integration/test_spoolman_inventory_bulk.py
  100. 1 0
      backend/tests/integration/test_spoolman_k_profiles.py

+ 11 - 0
.env.example

@@ -55,3 +55,14 @@ LOG_TO_FILE=true
 # In Docker, also bind-mount the host path into the container at the same
 # location (see docker-compose.yml for the matching volume snippet).
 # BAMBUDDY_EXTERNAL_ROOTS=
+
+# Local-login recovery bypass (#1589) — set to "true" / "1" / "yes" to
+# accept username + password credentials on /auth/login (and to allow the
+# /auth/forgot-password flow) even when the in-app setting "Disable local
+# login" is turned on. This is the documented "SSO is broken, let me back
+# in" path for an operator whose only normal sign-in route is via OIDC.
+# /auth/advanced-auth/status also reports local_login_enabled=true while
+# this is set, so the login page shows the credentials form to match.
+# LDAP is governed by its own ldap_enabled toggle and is not affected.
+# Leave unset for normal operation.
+# BAMBUDDY_LOCAL_LOGIN=true

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 55 - 0
CHANGELOG.md


+ 5 - 1
CONTRIBUTING.md

@@ -295,7 +295,11 @@ Permissions follow the `resource:action` pattern (e.g., `filaments:read`, `print
 | `update` | Modify existing resources |
 | `delete` | Remove resources |
 
-Some resources have additional actions (e.g., `printers:control` for start/stop, `printers:files` for file transfer).
+Some resources have additional actions. Examples: `printers:control` for live printer controls
+such as stop/pause/resume, `printers:files` for printer storage access, `queue:create` for
+creating queue items that may dispatch immediately when scheduled ASAP, `library:upload` for
+File Manager uploads/imports, and `archives:reprint_own` / `archives:reprint_all` for archive
+reprint eligibility. Archive reprint still needs `queue:create` before it can enqueue a job.
 
 ### Adding New Permissions
 

+ 2 - 0
backend/app/api/routes/_spoolman_helpers.py

@@ -55,6 +55,7 @@ class MappedSpoolFields(TypedDict):
     updated_at: str | None
     cost_per_kg: float | None
     storage_location: str | None
+    location_id: int | None
     k_profiles: list[Any]
 
 
@@ -346,5 +347,6 @@ def _map_spoolman_spool(spool: dict) -> MappedSpoolFields:
         "updated_at": created_at,
         "cost_per_kg": _safe_optional_float(spool.get("price")),
         "storage_location": spool.get("location") or None,
+        "location_id": None,
         "k_profiles": [],
     }

+ 15 - 87
backend/app/api/routes/archives.py

@@ -25,7 +25,7 @@ from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
-from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate, ReprintRequest
+from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
 from backend.app.schemas.print_log import PrintLogResponse
 from backend.app.schemas.slicer import SliceRequest
 from backend.app.services.archive import ArchiveService
@@ -4025,96 +4025,24 @@ async def slice_archive(
 async def reprint_archive(
     archive_id: int,
     printer_id: int,
-    body: ReprintRequest | None = None,
-    db: AsyncSession = Depends(get_db),
-    auth_result: tuple[User | None, bool] = Depends(
-        require_ownership_permission(
-            Permission.ARCHIVES_REPRINT_ALL,
-            Permission.ARCHIVES_REPRINT_OWN,
-        )
-    ),
+    # SECURITY.md SEC-AUTH-1: every route either has an explicit auth dep or
+    # is in the route-auth-coverage allowlist. Gating the deprecation stub on
+    # QUEUE_CREATE matches the replacement route (POST /queue/) and means
+    # anonymous callers bounce at auth instead of seeing the deprecation
+    # message — leaking "this route exists" to unauthenticated callers is
+    # exactly the shape the backstop guards against.
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
 ):
-    """Dispatch an archived 3MF file for send/start on a printer."""
-    from backend.app.models.printer import Printer
-    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
-    from backend.app.services.printer_manager import printer_manager
-
-    user, can_modify_all = auth_result
-
-    # Use defaults if no body provided
-    if body is None:
-        body = ReprintRequest()
-
-    # Get archive
-    service = ArchiveService(db)
-    archive = await service.get_archive(archive_id)
-    if not archive:
-        raise HTTPException(404, "Archive not found")
-
-    # Ownership check
-    if not can_modify_all:
-        if archive.created_by_id != user.id:
-            raise HTTPException(403, "You can only reprint your own archives")
-
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(404, "Printer not found")
-
-    # Check printer is connected
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(400, "Printer is not connected")
-
-    if not archive.file_path:
-        raise HTTPException(
-            404,
-            "No 3MF file available for this archive. "
-            "The file could not be downloaded from the printer when the print was recorded.",
-        )
-
-    # Validate archive file exists
-    file_path = settings.base_dir / archive.file_path
-    if not file_path.is_file():
-        raise HTTPException(404, "Archive file not found")
-
-    plate_name = body.plate_name
-    if not plate_name and body.plate_id is not None:
-        plate_name = f"Plate {body.plate_id}"
-
-    dispatch_source_name = archive.filename
-    if plate_name:
-        dispatch_source_name = f"{archive.filename} • {plate_name}"
-
-    try:
-        dispatch_result = await background_dispatch.dispatch_reprint_archive(
-            archive_id=archive_id,
-            archive_name=dispatch_source_name,
-            printer_id=printer_id,
-            printer_name=printer.name,
-            options=body.model_dump(exclude_none=True),
-            requested_by_user_id=user.id if user else None,
-            requested_by_username=user.username if user else None,
-        )
-    except DispatchEnqueueRejected as e:
-        raise HTTPException(status_code=409, detail=str(e)) from e
-
-    logger.info(
-        "Dispatched reprint archive %s for printer %s (dispatch_job_id=%s, dispatch_position=%s)",
+    """Legacy direct reprint endpoint. Use POST /queue/ instead."""
+    logger.warning(
+        "Gone API used: POST /archives/%s/reprint?printer_id=%s; use POST /queue/ instead",
         archive_id,
         printer_id,
-        dispatch_result["dispatch_job_id"],
-        dispatch_result["dispatch_position"],
     )
-
-    return {
-        "status": "dispatched",
-        "printer_id": printer_id,
-        "archive_id": archive_id,
-        "filename": archive.filename,
-        "dispatch_job_id": dispatch_result["dispatch_job_id"],
-        "dispatch_position": dispatch_result["dispatch_position"],
-    }
+    raise HTTPException(
+        status_code=410,
+        detail="Direct archive reprint has been removed. Create a print queue item with POST /queue/.",
+    )
 
 
 # =============================================================================

+ 92 - 6
backend/app/api/routes/auth.py

@@ -15,7 +15,6 @@ from sqlalchemy.orm import selectinload
 
 from backend.app.api.routes.settings import get_external_login_url
 from backend.app.core.auth import (
-    ACCESS_TOKEN_EXPIRE_MINUTES,
     ALGORITHM,
     SECRET_KEY,
     Permission,
@@ -31,6 +30,7 @@ from backend.app.core.auth import (
     get_user_by_email,
     get_user_by_username,
     is_jti_revoked,
+    resolve_session_max_minutes,
     revoke_jti,
     security,
 )
@@ -112,6 +112,19 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
 )
 
 
+# #1589: read at call time, not import time, so tests can monkeypatch os.environ
+# between cases without re-importing the module.
+def _local_login_env_bypass() -> bool:
+    """Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy.
+
+    Bypasses the ``local_login_enabled`` DB setting on the local-credentials
+    code path AND the forgot-password endpoint so a server admin can recover
+    an install whose SSO provider is unreachable. Accepted truthy values:
+    ``true``, ``1``, ``yes`` (case-insensitive).
+    """
+    return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
+
+
 def _get_client_ip(request: Request) -> str:
     """Return the real client IP for rate-limiting purposes.
 
@@ -378,6 +391,13 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     client_ip = _get_client_ip(raw_request)
     await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
 
+    # Initialize `user` up front so every downstream branch can read/write
+    # it without UnboundLocalError. The LDAP success path sets it inside its
+    # own block; the local-credentials and email-credentials paths set it
+    # below. The original code relied on the local-credentials path running
+    # unconditionally to bind `user`; #1589 made that path skippable, so the
+    # init has to live here.
+    user = None
     # Check if LDAP is enabled
     ldap_user = None
     ldap_settings = await _get_ldap_settings(db)
@@ -415,12 +435,30 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
             logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
             ldap_user = None
 
+    # #1589: local username/password gate. LDAP keeps its own switch
+    # (ldap_enabled) and is not affected — a delegated directory has its
+    # own policy and lockouts and is closer to SSO than to local creds.
+    # The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server
+    # admin can recover an install whose SSO provider is unreachable
+    # without editing the DB.
+    from backend.app.models.settings import Settings as _Settings_for_local_login
+
+    local_login_allowed = ldap_user is not None or _local_login_env_bypass()
+    if not local_login_allowed:
+        setting_row = await db.execute(
+            select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+        )
+        row = setting_row.scalar_one_or_none()
+        # Default True when the row is absent — matches AppSettings default
+        # so fresh installs and tests behave like every release before #1589.
+        local_login_allowed = row is None or row.value.lower() == "true"
+
     # Try username-based authentication (skip if already authenticated via LDAP)
-    if not ldap_user:
+    if not ldap_user and local_login_allowed:
         user = await authenticate_user(db, request.username, request.password)
 
     # If username auth failed and advanced auth is enabled, try email-based authentication
-    if not user and not ldap_user:
+    if not user and not ldap_user and local_login_allowed:
         advanced_auth = await is_advanced_auth_enabled(db)
         if advanced_auth:
             user = await authenticate_user_by_email(db, request.username, request.password)
@@ -428,6 +466,11 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
     if not user:
         await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
         await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
+        # Same generic 401 either way — never tell the client whether the
+        # username exists or whether local login was disabled. The Settings
+        # UI and /auth/advanced-auth/status are the channels for that state;
+        # leaking it here would help credential-stuffing distinguish "local
+        # disabled" from "wrong password" across an install fleet.
         raise HTTPException(
             status_code=status.HTTP_401_UNAUTHORIZED,
             detail="Incorrect username or password",
@@ -495,8 +538,9 @@ async def login(raw_request: Request, request: LoginRequest, response: Response,
             two_fa_methods=methods,
         )
 
-    # No 2FA — issue full token immediately
-    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
+    # No 2FA — issue full token immediately. Session lifetime honours the
+    # admin-configurable ceiling (#1706); resolver clamps to [1h, 720h].
+    access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db))
     access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
 
     return LoginResponse(
@@ -812,12 +856,39 @@ async def disable_advanced_auth(
 
 @router.get("/advanced-auth/status")
 async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
-    """Get advanced authentication status."""
+    """Get advanced authentication status.
+
+    Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
+    so the LoginPage can decide whether to render the credentials form and
+    whether to redirect unauthenticated visitors directly to an SSO
+    provider, in a single query. ``BAMBUDDY_LOCAL_LOGIN=true`` flips the
+    reported value back to True so the recovery path is visible.
+    """
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.models.settings import Settings as _Settings_for_local_login
+
     advanced_auth_enabled = await is_advanced_auth_enabled(db)
     smtp_configured = await get_smtp_settings(db) is not None
+
+    setting_row = await db.execute(
+        select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+    )
+    row = setting_row.scalar_one_or_none()
+    db_local_enabled = row is None or row.value.lower() == "true"
+    local_login_enabled = db_local_enabled or _local_login_env_bypass()
+
+    # Autologin provider must be both flagged AND enabled — disabling a
+    # provider should not silently keep redirecting visitors to it.
+    autologin = await db.execute(
+        select(OIDCProvider.id).where(OIDCProvider.is_autologin.is_(True), OIDCProvider.is_enabled.is_(True)).limit(1)
+    )
+    autologin_provider_id = autologin.scalar_one_or_none()
+
     return {
         "advanced_auth_enabled": advanced_auth_enabled,
         "smtp_configured": smtp_configured,
+        "local_login_enabled": local_login_enabled,
+        "autologin_provider_id": autologin_provider_id,
     }
 
 
@@ -883,6 +954,21 @@ async def forgot_password(
     secure link instead of a plaintext temporary password.  The new password is
     set only when the user clicks the link and POSTs to /forgot-password/confirm.
     """
+    # #1589: forgot-password is a local-credentials flow — useless when local
+    # login is disabled (the reset wouldn't grant access anyway). Same gate as
+    # /auth/login, with the same env-var bypass for SSO-broken recovery.
+    if not _local_login_env_bypass():
+        from backend.app.models.settings import Settings as _Settings_for_local_login
+
+        setting_row = await db.execute(
+            select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
+        )
+        row = setting_row.scalar_one_or_none()
+        if row is not None and row.value.lower() != "true":
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Local login is disabled — use SSO instead.",
+            )
     # Check if advanced auth is enabled
     advanced_auth = await is_advanced_auth_enabled(db)
     if not advanced_auth:

+ 0 - 32
backend/app/api/routes/background_dispatch.py

@@ -1,32 +0,0 @@
-from fastapi import APIRouter, HTTPException
-
-from backend.app.core.auth import RequirePermissionIfAuthEnabled
-from backend.app.core.permissions import Permission
-from backend.app.models.user import User
-from backend.app.services.background_dispatch import background_dispatch
-
-router = APIRouter(prefix="/background-dispatch", tags=["background-dispatch"])
-
-
-@router.delete("/{job_id}")
-async def cancel_dispatch_job(
-    job_id: int,
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
-):
-    """Cancel a background-dispatch job.
-
-    Queued jobs are cancelled immediately. Active jobs are marked for
-    cooperative cancellation and will stop at the next cancellation checkpoint.
-    """
-    result = await background_dispatch.cancel_job(job_id)
-
-    if not result["cancelled"]:
-        raise HTTPException(status_code=404, detail="Dispatch job not found")
-
-    return {
-        "status": "cancelling" if result.get("pending") else "cancelled",
-        "job_id": result["job_id"],
-        "source_name": result["source_name"],
-        "printer_id": result["printer_id"],
-        "printer_name": result["printer_name"],
-    }

+ 25 - 5
backend/app/api/routes/camera.py

@@ -35,6 +35,7 @@ from backend.app.services.camera import (
 from backend.app.services.camera_fanout import (
     MjpegBroadcaster,
     get_or_create_broadcaster,
+    get_subscriber_count,
     iter_subscriber,
     shutdown_broadcaster,
 )
@@ -771,17 +772,36 @@ async def stop_camera_stream(
     printer_id: int,
     _: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
 ):
-    """Stop all active camera streams for a printer.
-
-    This can be called by the frontend when the camera window is closed.
-    Accepts both GET and POST (POST for sendBeacon compatibility).
+    """Stop active camera streams for a printer.
+
+    Called by the frontend on viewer unmount (cam-wall tile, embedded viewer,
+    popup window). Accepts both GET and POST (POST for sendBeacon compatibility).
+
+    Reference-count guard: every viewer of a printer subscribes to the same
+    fan-out broadcaster, so a force-shutdown triggered by ONE leaving viewer
+    used to kill the others' streams (cam-wall tile froze when a user opened
+    then closed the embedded viewer). If any subscriber is still attached,
+    skip the force-teardown — the broadcaster's natural grace-shutdown (5 s
+    after subscribers drop to 0) handles cleanup when the leaving viewer's
+    HTTP connection actually closes.
     """
+    broadcaster_key = f"printer-{printer_id}"
+    remaining_subscribers = get_subscriber_count(broadcaster_key)
+    if remaining_subscribers >= 1:
+        logger.info(
+            "Skipping force-shutdown for printer %s: %d subscriber(s) still attached; "
+            "natural cleanup will tear down when last viewer disconnects",
+            printer_id,
+            remaining_subscribers,
+        )
+        return {"stopped": 0, "skipped": True}
+
     stopped = 0
 
     # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
     # all subscribed viewers and asks the upstream generator to stop
     # reconnecting before we fall back to forcefully killing the process below.
-    if await shutdown_broadcaster(f"printer-{printer_id}"):
+    if await shutdown_broadcaster(broadcaster_key):
         logger.info("Shut down camera fan-out broadcaster for printer %s", printer_id)
 
     # Stop ffmpeg/RTSP streams

+ 479 - 2
backend/app/api/routes/inventory.py

@@ -6,6 +6,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
 from fastapi.responses import Response, StreamingResponse
 from pydantic import BaseModel, Field, field_validator
 from sqlalchemy import delete, func, select
+from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -20,11 +21,14 @@ from backend.app.core.permissions import Permission
 from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.location import Location
+from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
 from backend.app.models.spool_k_profile import SpoolKProfile
 from backend.app.models.user import User
+from backend.app.schemas.location import LocationCreate, LocationResponse, LocationUpdate
 from backend.app.schemas.spool import (
     SpoolAssignmentCreate,
     SpoolAssignmentResponse,
@@ -38,6 +42,16 @@ from backend.app.schemas.spool import (
     normalize_extra_colors,
 )
 from backend.app.schemas.spool_usage import SpoolUsageHistoryResponse
+from backend.app.services.location_service import (
+    DUPLICATE_LOCATION_NAME,
+    assign_location_name,
+    count_internal_spools_at_location,
+    get_location_by_id,
+    get_location_by_name,
+    location_name_key,
+    prepare_internal_spool_payload,
+    rename_location as rename_location_record,
+)
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.spool_csv import (
     MAX_CSV_IMPORT_BYTES,
@@ -46,6 +60,7 @@ from backend.app.services.spool_csv import (
     parse_and_validate,
     serialize,
 )
+from backend.app.services.spoolman import SpoolmanClient, get_spoolman_client, init_spoolman_client
 from backend.app.utils.filament_ids import (
     GENERIC_FILAMENT_IDS,
     MATERIAL_TEMPS,
@@ -493,6 +508,198 @@ async def reset_spool_catalog(
     return {"status": "reset"}
 
 
+# ── Storage Locations (#1004) ───────────────────────────────────────────────
+
+
+async def _load_settings_map(db: AsyncSession) -> dict[str, str]:
+    result = await db.execute(select(Settings))
+    return {s.key: s.value for s in result.scalars().all()}
+
+
+def _spoolman_is_enabled(settings: dict[str, str]) -> bool:
+    return settings.get("spoolman_enabled", "false").lower() == "true"
+
+
+async def _ensure_spoolman_client(settings: dict[str, str]) -> SpoolmanClient | None:
+    if not _spoolman_is_enabled(settings):
+        return None
+    url = settings.get("spoolman_url", "").strip()
+    if not url:
+        return None
+    from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
+
+    try:
+        assert_safe_spoolman_url(url)
+    except ValueError:
+        return None
+    client = await get_spoolman_client()
+    if not client or client.base_url != url.rstrip("/"):
+        client = await init_spoolman_client(url)
+    return client
+
+
+async def _spool_counts_for_locations(
+    db: AsyncSession,
+    locations: list[Location],
+    settings: dict[str, str],
+) -> dict[int, int]:
+    if _spoolman_is_enabled(settings):
+        client = await _ensure_spoolman_client(settings)
+        if client:
+            try:
+                spools = await client.get_all_spools(allow_archived=False)
+            except Exception:
+                logger.warning("Failed to fetch Spoolman spools for location counts", exc_info=True)
+            else:
+                # Use the canonical key helper so this matches what the
+                # migration backfill, Location.name_key, and every other
+                # codepath store as the case-insensitive lookup key. Plain
+                # str.lower() drifts for non-ASCII (Turkish ı/İ, German ß)
+                # and caused mismatched delete-block counts in Spoolman mode.
+                by_key: dict[str, int] = {}
+                for spool in spools:
+                    raw = spool.get("location")
+                    if not raw or not isinstance(raw, str) or not raw.strip():
+                        continue
+                    try:
+                        key = location_name_key(raw)
+                    except ValueError:
+                        continue
+                    by_key[key] = by_key.get(key, 0) + 1
+                return {loc.id: by_key.get(loc.name_key, 0) for loc in locations}
+
+    counts: dict[int, int] = {}
+    for loc in locations:
+        counts[loc.id] = await count_internal_spools_at_location(db, loc.id)
+    return counts
+
+
+def _location_to_response(location: Location, spool_count: int) -> LocationResponse:
+    return LocationResponse(
+        id=location.id,
+        name=location.name,
+        identifier=location.identifier,
+        spool_count=spool_count,
+        created_at=location.created_at,
+        updated_at=location.updated_at,
+    )
+
+
+@router.get("/locations", response_model=list[LocationResponse])
+async def list_locations(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
+):
+    """List all storage locations with spool counts."""
+    settings = await _load_settings_map(db)
+    result = await db.execute(select(Location).order_by(Location.name))
+    locations = list(result.scalars().all())
+    counts = await _spool_counts_for_locations(db, locations, settings)
+    return [_location_to_response(loc, counts.get(loc.id, 0)) for loc in locations]
+
+
+@router.post("/locations", response_model=LocationResponse, status_code=201)
+async def create_location(
+    data: LocationCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Create a storage location."""
+    existing = await get_location_by_name(db, data.name)
+    if existing:
+        raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME)
+    location = Location(identifier=data.identifier)
+    assign_location_name(location, data.name)
+    db.add(location)
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
+    await db.refresh(location)
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return _location_to_response(location, 0)
+
+
+@router.patch("/locations/{location_id}", response_model=LocationResponse)
+async def update_location(
+    location_id: int,
+    data: LocationUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Update a storage location (rename propagates to assigned spools)."""
+    location = await get_location_by_id(db, location_id)
+    if not location:
+        raise HTTPException(status_code=404, detail="Location not found")
+
+    old_name = location.name
+    if data.identifier is not None:
+        location.identifier = data.identifier or None
+
+    if data.name is not None and data.name != old_name:
+        try:
+            await rename_location_record(db, location, data.name)
+        except ValueError as exc:
+            raise HTTPException(status_code=409, detail=str(exc)) from exc
+
+        # Cascade to Spoolman BEFORE the local commit so a Spoolman failure
+        # rolls back the local rename instead of leaving the catalog and
+        # Spoolman's per-spool `location` field permanently diverged. Without
+        # this ordering, a partial failure makes the next location-sync recreate
+        # the old name as a duplicate catalog row (#1505 review blocker).
+        settings = await _load_settings_map(db)
+        client = await _ensure_spoolman_client(settings)
+        if client:
+            try:
+                await client.rename_location(old_name, location.name)
+            except Exception as exc:
+                logger.warning(
+                    "Spoolman location rename failed for %s -> %s: %s",
+                    old_name,
+                    location.name,
+                    exc,
+                )
+                await db.rollback()
+                raise HTTPException(
+                    status_code=502,
+                    detail="Spoolman rename failed; local rename rolled back",
+                ) from exc
+
+    try:
+        await db.commit()
+    except IntegrityError as exc:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail=DUPLICATE_LOCATION_NAME) from exc
+    await db.refresh(location)
+    settings = await _load_settings_map(db)
+    counts = await _spool_counts_for_locations(db, [location], settings)
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return _location_to_response(location, counts.get(location.id, 0))
+
+
+@router.delete("/locations/{location_id}")
+async def delete_location(
+    location_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Delete a storage location when no spools are assigned."""
+    location = await get_location_by_id(db, location_id)
+    if not location:
+        raise HTTPException(status_code=404, detail="Location not found")
+
+    settings = await _load_settings_map(db)
+    counts = await _spool_counts_for_locations(db, [location], settings)
+    if counts.get(location.id, 0) > 0:
+        raise HTTPException(status_code=409, detail="Location has spools assigned and cannot be deleted")
+
+    await db.delete(location)
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"status": "deleted"}
+
+
 # ── Color Catalog CRUD ─────────────────────────────────────────────────────
 
 
@@ -974,6 +1181,49 @@ async def import_spools_csv(
     )
 
 
+@router.get("/spools/by-tag", response_model=SpoolResponse)
+async def get_spool_by_tag(
+    tray_uuid: str | None = None,
+    tag_uid: str | None = None,
+    include_archived: bool = False,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.INVENTORY_READ, Permission.INVENTORY_UPDATE),
+):
+    """Find a single spool by its NFC ``tray_uuid`` and/or ``tag_uid``.
+
+    Lets NFC inventory integrations dedupe a scan without listing the whole
+    inventory. ``tray_uuid`` is the primary identifier (it matches the value the
+    AMS reports over MQTT), so it is tried first; ``tag_uid`` is the fallback.
+    At least one identifier must be supplied. Returns 404 when nothing matches.
+
+    Accepts ``inventory:read`` OR ``inventory:update`` so a Manage-Inventory API
+    key (which has ``inventory:update`` via ``can_manage_inventory``) can read a
+    spool back without widening the global ``INVENTORY_READ`` scope mapping (#1663).
+    """
+    normalized_tray_uuid = normalize_tray_uuid(tray_uuid) or None
+    normalized_tag_uid = normalize_tag_uid(tag_uid) or None
+
+    if not normalized_tray_uuid and not normalized_tag_uid:
+        raise HTTPException(400, "Provide tray_uuid and/or tag_uid")
+
+    base_query = select(Spool).options(selectinload(Spool.k_profiles))
+    if not include_archived:
+        base_query = base_query.where(Spool.archived_at.is_(None))
+
+    for column, value in (
+        (Spool.tray_uuid, normalized_tray_uuid),
+        (Spool.tag_uid, normalized_tag_uid),
+    ):
+        if not value:
+            continue
+        result = await db.execute(base_query.where(func.upper(column) == value).order_by(Spool.id))
+        spool = result.scalars().first()
+        if spool:
+            return spool
+
+    raise HTTPException(404, "Spool not found")
+
+
 @router.get("/spools/{spool_id}", response_model=SpoolResponse)
 async def get_spool(
     spool_id: int,
@@ -995,7 +1245,11 @@ async def create_spool(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Create a new spool."""
-    spool = Spool(**spool_data.model_dump())
+    try:
+        payload = await prepare_internal_spool_payload(db, spool_data.model_dump(), set(spool_data.model_fields_set))
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    spool = Spool(**payload)
     db.add(spool)
     await db.commit()
     await db.refresh(spool)
@@ -1012,8 +1266,13 @@ async def bulk_create_spools(
 ):
     """Create multiple identical spools."""
     spools = []
+    fields_set = set(data.spool.model_fields_set)
+    try:
+        payload = await prepare_internal_spool_payload(db, data.spool.model_dump(), fields_set)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
     for _ in range(data.quantity):
-        spool = Spool(**data.spool.model_dump())
+        spool = Spool(**payload)
         db.add(spool)
         spools.append(spool)
     await db.commit()
@@ -1037,6 +1296,10 @@ async def update_spool(
         raise HTTPException(404, "Spool not found")
 
     update_data = spool_data.model_dump(exclude_unset=True)
+    try:
+        update_data = await prepare_internal_spool_payload(db, update_data, set(spool_data.model_fields_set))
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
     # Auto-lock weight when user explicitly sets weight_used
     if "weight_used" in update_data and "weight_locked" not in update_data:
         update_data["weight_locked"] = True
@@ -1168,6 +1431,126 @@ async def bulk_reset_spool_consumed_counter(
     return {"reset": len(spools)}
 
 
+class BulkUpdateRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+    update: SpoolUpdate
+
+
+class BulkIdsRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+
+
+@router.post("/spools/bulk-update")
+async def bulk_update_spools(
+    payload: BulkUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Apply the same partial update to every listed spool.
+
+    Per-spool errors are collected and returned alongside the success count so
+    a single bad ID doesn't abort the whole batch. Unknown IDs are reported
+    in the ``not_found`` list.
+    """
+    update_data = payload.update.model_dump(exclude_unset=True)
+    fields_set = set(payload.update.model_fields_set)
+    if not update_data:
+        raise HTTPException(status_code=400, detail="update must include at least one field")
+    try:
+        prepared = await prepare_internal_spool_payload(db, update_data, fields_set)
+    except ValueError as exc:
+        raise HTTPException(status_code=400, detail=str(exc)) from exc
+    # Auto-lock weight when the user explicitly sets weight_used — mirrors the
+    # per-spool PATCH behaviour so bulk edits don't desync the lock state.
+    if "weight_used" in prepared and "weight_locked" not in prepared:
+        prepared["weight_locked"] = True
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = {s.id: s for s in result.scalars().all()}
+    not_found = [sid for sid in payload.ids if sid not in spools]
+    updated_ids: list[int] = []
+    for sid, spool in spools.items():
+        for field, value in prepared.items():
+            setattr(spool, field, value)
+        updated_ids.append(sid)
+    await db.commit()
+    if updated_ids:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": len(updated_ids), "not_found": not_found}
+
+
+@router.post("/spools/bulk-delete")
+async def bulk_delete_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Hard-delete every listed spool. Unknown IDs are returned in not_found."""
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    for spool in spools:
+        await db.delete(spool)
+    await db.commit()
+    if spools:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"deleted": len(spools), "not_found": not_found}
+
+
+@router.post("/spools/bulk-archive")
+async def bulk_archive_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
+    from datetime import datetime, timezone
+
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    archived: list[int] = []
+    already: list[int] = []
+    now = datetime.now(timezone.utc)
+    for spool in spools:
+        if spool.archived_at is not None:
+            already.append(spool.id)
+            continue
+        spool.archived_at = now
+        archived.append(spool.id)
+    await db.commit()
+    if archived:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"archived": len(archived), "already_archived": already, "not_found": not_found}
+
+
+@router.post("/spools/bulk-restore")
+async def bulk_restore_spools(
+    payload: BulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Restore every listed archived spool. Non-archived rows are no-ops counted in already_active."""
+    result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
+    spools = list(result.scalars().all())
+    found_ids = {s.id for s in spools}
+    not_found = [sid for sid in payload.ids if sid not in found_ids]
+    restored: list[int] = []
+    already: list[int] = []
+    for spool in spools:
+        if spool.archived_at is None:
+            already.append(spool.id)
+            continue
+        spool.archived_at = None
+        restored.append(spool.id)
+    await db.commit()
+    if restored:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"restored": len(restored), "already_active": already, "not_found": not_found}
+
+
 # ── K-Profiles ───────────────────────────────────────────────────────────────
 
 
@@ -1781,6 +2164,7 @@ class FilamentSkuSettingsResponse(BaseModel):
     material: str
     subtype: str | None
     brand: str | None
+    color_name: str | None
     lead_time_days: int
     safety_margin_value: int
     safety_margin_unit: str
@@ -1794,6 +2178,7 @@ class FilamentSkuSettingsUpsert(BaseModel):
     material: str
     subtype: str | None = None
     brand: str | None = None
+    color_name: str | None = None
     lead_time_days: int = 0
     safety_margin_value: int = 14
     safety_margin_unit: str = "days"
@@ -1830,6 +2215,7 @@ async def upsert_sku_settings(
             FilamentSkuSettings.material == data.material,
             FilamentSkuSettings.subtype == data.subtype,
             FilamentSkuSettings.brand == data.brand,
+            FilamentSkuSettings.color_name == data.color_name,
         )
     )
     row = result.scalar_one_or_none()
@@ -1843,6 +2229,7 @@ async def upsert_sku_settings(
             material=data.material,
             subtype=data.subtype,
             brand=data.brand,
+            color_name=data.color_name,
             lead_time_days=data.lead_time_days,
             safety_margin_value=data.safety_margin_value,
             safety_margin_unit=data.safety_margin_unit,
@@ -1862,6 +2249,7 @@ class ShoppingListItemResponse(BaseModel):
     material: str
     subtype: str | None
     brand: str | None
+    color_name: str | None
     quantity_spools: int
     note: str | None
     status: str
@@ -1876,6 +2264,7 @@ class ShoppingListItemCreate(BaseModel):
     material: str
     subtype: str | None = None
     brand: str | None = None
+    color_name: str | None = None
     quantity_spools: int = 1
     note: str | None = None
 
@@ -1900,6 +2289,7 @@ async def get_shopping_list(
             material=i.material,
             subtype=i.subtype,
             brand=i.brand,
+            color_name=i.color_name,
             quantity_spools=i.quantity_spools,
             note=i.note,
             status=i.status or "pending",
@@ -1925,6 +2315,7 @@ async def add_to_shopping_list(
         material=data.material,
         subtype=data.subtype,
         brand=data.brand,
+        color_name=data.color_name,
         quantity_spools=data.quantity_spools,
         note=data.note,
     )
@@ -1936,6 +2327,7 @@ async def add_to_shopping_list(
         material=item.material,
         subtype=item.subtype,
         brand=item.brand,
+        color_name=item.color_name,
         quantity_spools=item.quantity_spools,
         note=item.note,
         status=item.status or "pending",
@@ -1979,6 +2371,7 @@ async def update_shopping_list_status(
         material=item.material,
         subtype=item.subtype,
         brand=item.brand,
+        color_name=item.color_name,
         quantity_spools=item.quantity_spools,
         note=item.note,
         status=item.status or "pending",
@@ -2021,3 +2414,87 @@ async def clear_shopping_list(
     deleted = len(result.fetchall())
     await db.commit()
     return {"deleted": deleted}
+
+
+class CreateSpoolFromSlotRequest(BaseModel):
+    printer_id: int
+    ams_id: int
+    tray_id: int
+
+
+@router.post("/spools/from-slot", response_model=SpoolResponse)
+async def create_spool_from_slot(
+    req: CreateSpoolFromSlotRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+):
+    """Explicit user action: create an inventory spool from an AMS slot's current tray data.
+
+    Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
+    the user looked at the slot and chose to register it. Also assigns the new spool
+    to the slot in the same call.
+    """
+    from backend.app.services.printer_manager import printer_manager
+    from backend.app.services.spool_tag_matcher import auto_assign_spool, create_spool_from_tray
+
+    state = printer_manager.get_status(req.printer_id)
+    if not state or not state.raw_data:
+        raise HTTPException(status_code=404, detail="Printer not connected or no state available")
+
+    ams_data = state.raw_data.get("ams")
+    ams_units: list[dict] = []
+    if isinstance(ams_data, list):
+        ams_units = ams_data
+    elif isinstance(ams_data, dict):
+        if "ams" in ams_data and isinstance(ams_data["ams"], list):
+            ams_units = ams_data["ams"]
+        elif "tray" in ams_data:
+            ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
+
+    tray: dict | None = None
+    for unit in ams_units:
+        if not isinstance(unit, dict):
+            continue
+        if int(unit.get("id", -1)) != req.ams_id:
+            continue
+        for t in unit.get("tray", []):
+            if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
+                tray = t
+                break
+        if tray:
+            break
+
+    if not tray or not tray.get("tray_type"):
+        raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
+
+    # Guard against ghost-spool creation: a slot without any RFID tag has no
+    # stable identity, so creating an inventory row would just duplicate on
+    # every confirm and never re-link to the physical spool.
+    from backend.app.services.spool_tag_matcher import is_valid_tag
+
+    if not is_valid_tag(tray.get("tag_uid", ""), tray.get("tray_uuid", "")):
+        raise HTTPException(status_code=400, detail="Slot has no RFID tag")
+
+    spool = await create_spool_from_tray(db, tray)
+    await auto_assign_spool(
+        req.printer_id,
+        req.ams_id,
+        req.tray_id,
+        spool,
+        printer_manager,
+        db,
+        tray_info_idx=tray.get("tray_info_idx", ""),
+    )
+    await db.commit()
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    await ws_manager.broadcast(
+        {
+            "type": "spool_auto_assigned",
+            "printer_id": req.printer_id,
+            "ams_id": req.ams_id,
+            "tray_id": req.tray_id,
+            "spool_id": spool.id,
+        }
+    )
+    result = await db.execute(select(Spool).options(selectinload(Spool.k_profiles)).where(Spool.id == spool.id))
+    return result.scalar_one()

+ 203 - 121
backend/app/api/routes/library.py

@@ -16,7 +16,7 @@ from pathlib import Path
 
 from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile
 from fastapi.responses import FileResponse as FastAPIFileResponse
-from sqlalchemy import func, select
+from sqlalchemy import distinct, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -31,7 +31,7 @@ from backend.app.core.database import async_session, get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.tasks import spawn_background_task
 from backend.app.models.archive import PrintArchive
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryFolder
 from backend.app.models.print_queue import PrintQueueItem
 from backend.app.models.project import Project
 from backend.app.models.user import User
@@ -49,20 +49,22 @@ from backend.app.schemas.library import (
     FileDuplicate,
     FileListResponse,
     FileMoveRequest,
-    FilePrintRequest,
     FileResponse as FileResponseSchema,
     FileUpdate,
     FileUploadResponse,
     FolderCreate,
+    FolderReadmeResponse,
     FolderResponse,
     FolderTreeItem,
     FolderUpdate,
+    TagSummary,
     ZipExtractError,
     ZipExtractResponse,
     ZipExtractResult,
 )
 from backend.app.schemas.slicer import SliceRequest, SliceResponse
 from backend.app.services.archive import ThreeMFParser
+from backend.app.services.plate_thumbnail import inject_plate_thumbnails_if_missing
 from backend.app.services.stl_thumbnail import MIN_USABLE_STL_BYTES, generate_stl_thumbnail
 from backend.app.utils.filename import InvalidFilenameError, validate_print_filename
 from backend.app.utils.threemf_tools import (
@@ -193,11 +195,11 @@ def validate_print_file_upload(filename: str, content: bytes) -> None:
     — 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
+    the queue dispatch path 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
@@ -749,11 +751,24 @@ async def list_folders(
     )
     file_counts = dict(file_counts_result.all())
 
+    # Latest immediate-child file activity per folder (#1770). Sibling of the
+    # file_counts subquery — same WHERE clause, MAX(updated_at) instead of
+    # COUNT(id). Subfolder descent is not aggregated here; the frontend's
+    # "sort by recent activity" mode is satisfied by immediate-parent bubble.
+    latest_file_activity_result = await db.execute(
+        select(LibraryFile.folder_id, func.max(LibraryFile.updated_at))
+        .where(LibraryFile.folder_id.isnot(None), LibraryFile.deleted_at.is_(None))
+        .group_by(LibraryFile.folder_id)
+    )
+    latest_file_activity = dict(latest_file_activity_result.all())
+
     # Build tree structure
     folder_map = {}
     root_folders = []
 
     for folder, project_name, archive_name in rows:
+        latest_file = latest_file_activity.get(folder.id)
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
         folder_item = FolderTreeItem(
             id=folder.id,
             name=folder.name,
@@ -766,6 +781,7 @@ async def list_folders(
             external_path=folder.external_path,
             external_readonly=folder.external_readonly,
             file_count=file_counts.get(folder.id, 0),
+            latest_activity_at=latest_activity_at,
             children=[],
         )
         folder_map[folder.id] = folder_item
@@ -803,14 +819,19 @@ async def get_folders_by_project(
 
     folders = []
     for folder, project_name in rows:
-        # Get file count
-        file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(
+        # Get file count + latest file activity (#1770) in one trip
+        agg_result = await db.execute(
+            select(
+                func.count(LibraryFile.id),
+                func.max(LibraryFile.updated_at),
+            ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
             )
         )
-        file_count = file_count_result.scalar() or 0
+        file_count, latest_file = agg_result.one()
+        file_count = file_count or 0
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
         folders.append(
             FolderResponse(
@@ -826,6 +847,7 @@ async def get_folders_by_project(
                 external_readonly=folder.external_readonly,
                 external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
+                latest_activity_at=latest_activity_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
             )
@@ -856,14 +878,19 @@ async def get_folders_by_archive(
 
     folders = []
     for folder, archive_name in rows:
-        # Get file count
-        file_count_result = await db.execute(
-            select(func.count(LibraryFile.id)).where(
+        # Get file count + latest file activity (#1770) in one trip
+        agg_result = await db.execute(
+            select(
+                func.count(LibraryFile.id),
+                func.max(LibraryFile.updated_at),
+            ).where(
                 LibraryFile.folder_id == folder.id,
                 LibraryFile.deleted_at.is_(None),
             )
         )
-        file_count = file_count_result.scalar() or 0
+        file_count, latest_file = agg_result.one()
+        file_count = file_count or 0
+        latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
         folders.append(
             FolderResponse(
@@ -879,6 +906,7 @@ async def get_folders_by_archive(
                 external_readonly=folder.external_readonly,
                 external_show_hidden=folder.external_show_hidden,
                 file_count=file_count,
+                latest_activity_at=latest_activity_at,
                 created_at=folder.created_at,
                 updated_at=folder.updated_at,
             )
@@ -942,6 +970,9 @@ async def create_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=0,
+        # New folder has no files yet — fall back to the folder's own
+        # updated_at so this matches the list-route semantics (#1770).
+        latest_activity_at=folder.updated_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
@@ -972,14 +1003,19 @@ async def get_folder(
 
     folder, project_name, archive_name = row
 
-    # Get file count
-    file_count_result = await db.execute(
-        select(func.count(LibraryFile.id)).where(
+    # Get file count + latest file activity (#1770) in one trip
+    agg_result = await db.execute(
+        select(
+            func.count(LibraryFile.id),
+            func.max(LibraryFile.updated_at),
+        ).where(
             LibraryFile.folder_id == folder_id,
             LibraryFile.deleted_at.is_(None),
         )
     )
-    file_count = file_count_result.scalar() or 0
+    file_count, latest_file = agg_result.one()
+    file_count = file_count or 0
+    latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
     return FolderResponse(
         id=folder.id,
@@ -994,11 +1030,78 @@ async def get_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
+        latest_activity_at=latest_activity_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
 
 
+_README_BYTES_CAP = 512 * 1024  # 512 KiB — model descriptions don't need more
+_README_PREFERRED_STEMS = ("readme", "description")
+
+
+@router.get("/folders/{folder_id}/readme", response_model=FolderReadmeResponse)
+async def get_folder_readme(
+    folder_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+):
+    """Return the first markdown description file for a folder (#1268).
+
+    Picks ``README.md`` / ``readme.md`` / ``description.md`` first (any case),
+    otherwise the alphabetically-first ``*.md`` in the folder. 404 when no
+    markdown file is present so the FE can hide the side panel.
+    """
+    user, can_read_all = auth_result
+
+    folder_row = await db.execute(select(LibraryFolder.id).where(LibraryFolder.id == folder_id))
+    if folder_row.scalar_one_or_none() is None:
+        raise HTTPException(status_code=404, detail="Folder not found")
+
+    query = LibraryFile.active().where(
+        LibraryFile.folder_id == folder_id,
+        func.lower(LibraryFile.filename).like("%.md"),
+    )
+    if user is not None and not can_read_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+    result = await db.execute(query)
+    candidates = result.scalars().all()
+    if not candidates:
+        raise HTTPException(status_code=404, detail="No markdown description in folder")
+
+    def sort_key(f: LibraryFile) -> tuple[int, str]:
+        stem = os.path.splitext(f.filename.lower())[0]
+        try:
+            return (_README_PREFERRED_STEMS.index(stem), f.filename.lower())
+        except ValueError:
+            return (len(_README_PREFERRED_STEMS), f.filename.lower())
+
+    pick = sorted(candidates, key=sort_key)[0]
+
+    abs_path = to_absolute_path(pick.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="Markdown file missing on disk")
+
+    try:
+        raw = abs_path.read_bytes()
+    except OSError as e:
+        logger.warning("Folder readme read failed for %s: %s", abs_path, e)
+        raise HTTPException(status_code=500, detail="Could not read markdown file") from None
+
+    truncated = len(raw) > _README_BYTES_CAP
+    if truncated:
+        raw = raw[:_README_BYTES_CAP]
+    # `errors="replace"` so a single bad byte never blanks the panel.
+    content = raw.decode("utf-8", errors="replace")
+
+    return FolderReadmeResponse(filename=pick.filename, content=content, truncated=truncated)
+
+
 @router.put("/folders/{folder_id}", response_model=FolderResponse)
 async def update_folder(
     folder_id: int,
@@ -1063,14 +1166,19 @@ async def update_folder(
     await db.commit()
     await db.refresh(folder)
 
-    # Get file count and names
-    file_count_result = await db.execute(
-        select(func.count(LibraryFile.id)).where(
+    # Get file count + latest file activity (#1770) and names
+    agg_result = await db.execute(
+        select(
+            func.count(LibraryFile.id),
+            func.max(LibraryFile.updated_at),
+        ).where(
             LibraryFile.folder_id == folder_id,
             LibraryFile.deleted_at.is_(None),
         )
     )
-    file_count = file_count_result.scalar() or 0
+    file_count, latest_file = agg_result.one()
+    file_count = file_count or 0
+    latest_activity_at = max(folder.updated_at, latest_file) if latest_file is not None else folder.updated_at
 
     # Get project and archive names
     project_name = None
@@ -1095,6 +1203,7 @@ async def update_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=file_count,
+        latest_activity_at=latest_activity_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
@@ -1364,6 +1473,9 @@ async def create_external_folder(
         external_readonly=folder.external_readonly,
         external_show_hidden=folder.external_show_hidden,
         file_count=0,
+        # Newly-created external folder hasn't been scanned yet — fall back
+        # to the folder's own updated_at (#1770).
+        latest_activity_at=folder.updated_at,
         created_at=folder.created_at,
         updated_at=folder.updated_at,
     )
@@ -1677,6 +1789,8 @@ async def list_files(
     include_root: bool = True,
     internal_only: bool = False,
     external_only: bool = False,
+    recursive: bool = False,
+    tag_ids: list[int] = Query(default_factory=list),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -1698,6 +1812,16 @@ async def list_files(
         external_only: Restrict the result to files under external folders
                        (`is_external=True`) — the symmetric combined view for users with
                        multiple linked external sources (#1621).
+        recursive: When combined with ``folder_id``, also include files in every
+                   descendant subfolder (#1268). Implemented via a recursive CTE
+                   that walks ``library_folders.parent_id``. Default off so
+                   existing callers (folder browsing, etc.) keep their narrow
+                   single-folder semantics.
+        tag_ids: Restrict the listing to files carrying ALL of these tags
+                 (AND semantics, #1268). When non-empty the folder filter is
+                 intentionally bypassed — tags are cross-cutting and the user
+                 wants "every file with this tag" regardless of where it lives.
+                 ``recursive`` becomes irrelevant in that case.
     """
     if internal_only and external_only:
         raise HTTPException(
@@ -1706,11 +1830,36 @@ async def list_files(
         )
 
     user, can_read_all = auth_result
-    query = LibraryFile.active().options(selectinload(LibraryFile.created_by))
+    query = LibraryFile.active().options(
+        selectinload(LibraryFile.created_by),
+        selectinload(LibraryFile.tags),
+    )
     if user is not None and not can_read_all:
         query = query.where(LibraryFile.created_by_id == user.id)
 
-    if folder_id is not None:
+    if tag_ids:
+        # Cross-cutting filter — every requested tag must be present on the
+        # file. JOIN + GROUP BY + HAVING COUNT(DISTINCT) is portable across
+        # SQLite and Postgres without dialect tricks. We deliberately skip
+        # the folder / project / include_root scoping below so the result
+        # is the global "all files carrying these tags".
+        unique_tag_ids = list(dict.fromkeys(tag_ids))
+        query = (
+            query.join(LibraryFileTag, LibraryFileTag.file_id == LibraryFile.id)
+            .where(LibraryFileTag.tag_id.in_(unique_tag_ids))
+            .group_by(LibraryFile.id)
+            .having(func.count(distinct(LibraryFileTag.tag_id)) == len(unique_tag_ids))
+        )
+    elif folder_id is not None and recursive:
+        # Walk the subtree starting at folder_id and collect every descendant
+        # id. Recursive CTE works on both SQLite (>=3.8.3, shipped 2014) and
+        # Postgres without dialect branching.
+        roots = (
+            select(LibraryFolder.id).where(LibraryFolder.id == folder_id).cte(name="folder_descendants", recursive=True)
+        )
+        descendants = roots.union_all(select(LibraryFolder.id).join(roots, LibraryFolder.parent_id == roots.c.id))
+        query = query.where(LibraryFile.folder_id.in_(select(descendants.c.id)))
+    elif folder_id is not None:
         query = query.where(LibraryFile.folder_id == folder_id)
     elif project_id is not None:
         # Single join instead of one query per folder (avoids N+1 pattern)
@@ -1726,7 +1875,7 @@ async def list_files(
 
     query = query.order_by(LibraryFile.filename)
     result = await db.execute(query)
-    files = result.scalars().all()
+    files = result.scalars().unique().all() if tag_ids else result.scalars().all()
 
     # Get duplicate counts
     hash_counts = {}
@@ -1774,6 +1923,7 @@ async def list_files(
                 print_time_seconds=print_time,
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
+                tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
             )
         )
 
@@ -3590,6 +3740,11 @@ async def slice_and_persist(
     out_filename = f"{base_name}.gcode.3mf"
     unique_name = f"{uuid.uuid4().hex}.gcode.3mf"
     out_path = get_library_files_dir() / unique_name  # SEC-PATH-OK: unique_name = uuid.uuid4().hex + ".gcode.3mf"
+    # BS/Orca CLIs skip plate_N.png in headless --export-3mf — render +
+    # inject server-side so the library card has a thumbnail. Best-effort:
+    # no-op when the slicer did embed thumbs (desktop Studio path), and
+    # falls through to the unmodified bytes on any render error.
+    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
     out_path.write_bytes(result.content)
 
     # Extract thumbnail from the produced 3MF so the library card shows a
@@ -3715,6 +3870,11 @@ async def slice_and_persist_as_archive(
     out_path = (
         archive_dir / out_filename
     )  # SEC-PATH-OK: out_filename = f"{base_name}.gcode.3mf" where base_name went through _safe_filename
+    # See library-slice path: BS/Orca sidecar CLIs don't embed plate_N.png
+    # in headless --export-3mf, so the produced 3MF often has no thumbnail
+    # at all. Server-side render fills the gap; no-op when the slicer did
+    # embed (desktop Studio path) and best-effort on any render error.
+    result = result._replace(content=inject_plate_thumbnails_if_missing(result.content))
     out_path.write_bytes(result.content)
 
     # Extract a thumbnail for the new archive card. Priority order:
@@ -3956,101 +4116,23 @@ async def slice_library_file(
 async def print_library_file(
     file_id: int,
     printer_id: int,
-    body: FilePrintRequest | None = None,
-    db: AsyncSession = Depends(get_db),
-    current_user: User | None = Depends(require_permission_if_auth_enabled(Permission.PRINTERS_CONTROL)),
+    # SECURITY.md SEC-AUTH-1: every route either has an explicit auth dep or
+    # is in the route-auth-coverage allowlist. Gating the deprecation stub on
+    # QUEUE_CREATE matches the replacement route (POST /queue/) and means
+    # anonymous callers bounce at auth instead of seeing the deprecation
+    # message.
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.QUEUE_CREATE)),
 ):
-    """Dispatch a library file for send/start on a printer.
-
-    The actual send/start work is handled asynchronously by background
-    dispatch so the UI can continue immediately.
-
-    Only sliced files (.gcode or .gcode.3mf) can be printed.
-    """
-    from backend.app.models.printer import Printer
-    from backend.app.services.background_dispatch import DispatchEnqueueRejected, background_dispatch
-    from backend.app.services.printer_manager import printer_manager
-
-    # Use defaults if no body provided
-    if body is None:
-        body = FilePrintRequest()
-
-    # Get the library file
-    result = await db.execute(LibraryFile.active().where(LibraryFile.id == file_id))
-    lib_file = result.scalar_one_or_none()
-
-    if not lib_file:
-        raise HTTPException(status_code=404, detail="File not found")
-
-    # Validate file is sliced
-    if not is_sliced_file(lib_file.filename):
-        raise HTTPException(
-            status_code=400,
-            detail="Not a sliced file. Only .gcode or .gcode.3mf files can be printed.",
-        )
-
-    # Filenames containing FAT32/exFAT-illegal characters would 553 at
-    # FTP upload time (#1540). Older rows may pre-date the rename-time
-    # validation, so reject the print attempt with an actionable message
-    # rather than silently renaming user data.
-    try:
-        validate_print_filename(lib_file.filename)
-    except InvalidFilenameError as e:
-        raise HTTPException(status_code=400, detail=str(e)) from e
-
-    # Get the full file path
-    file_path = Path(app_settings.base_dir) / lib_file.file_path
-
-    if not file_path.exists():
-        raise HTTPException(status_code=404, detail="File not found on disk")
-
-    # Get printer
-    result = await db.execute(select(Printer).where(Printer.id == printer_id))
-    printer = result.scalar_one_or_none()
-    if not printer:
-        raise HTTPException(status_code=404, detail="Printer not found")
-
-    # Check printer is connected
-    if not printer_manager.is_connected(printer_id):
-        raise HTTPException(status_code=400, detail="Printer is not connected")
-
-    # Validate project exists before dispatching so a bogus ID yields 404, not a FK-constraint 500
-    if body.project_id is not None:
-        project_result = await db.execute(select(Project).where(Project.id == body.project_id))
-        if not project_result.scalar_one_or_none():
-            raise HTTPException(status_code=404, detail="Project not found")
-
-    plate_name = body.plate_name
-    if not plate_name and body.plate_id is not None:
-        plate_name = f"Plate {body.plate_id}"
-
-    dispatch_source_name = lib_file.filename
-    if plate_name:
-        dispatch_source_name = f"{lib_file.filename} • {plate_name}"
-
-    try:
-        dispatch_result = await background_dispatch.dispatch_print_library_file(
-            file_id=file_id,
-            filename=dispatch_source_name,
-            printer_id=printer_id,
-            printer_name=printer.name,
-            options=body.model_dump(exclude_none=True, exclude={"cleanup_library_after_dispatch"}),
-            project_id=body.project_id,
-            requested_by_user_id=current_user.id if current_user else None,
-            requested_by_username=current_user.username if current_user else None,
-            cleanup_library_after_dispatch=body.cleanup_library_after_dispatch,
-        )
-    except DispatchEnqueueRejected as e:
-        raise HTTPException(status_code=409, detail=str(e)) from e
-
-    return {
-        "status": "dispatched",
-        "printer_id": printer_id,
-        "archive_id": None,
-        "filename": lib_file.filename,
-        "dispatch_job_id": dispatch_result["dispatch_job_id"],
-        "dispatch_position": dispatch_result["dispatch_position"],
-    }
+    """Legacy direct library print endpoint. Use POST /queue/ instead."""
+    logger.warning(
+        "Gone API used: POST /library/files/%s/print?printer_id=%s; use POST /queue/ instead",
+        file_id,
+        printer_id,
+    )
+    raise HTTPException(
+        status_code=410,
+        detail="Direct library-file print has been removed. Create a print queue item with POST /queue/.",
+    )
 
 
 # ============ File Detail Endpoints ============

+ 300 - 0
backend/app/api/routes/library_tags.py

@@ -0,0 +1,300 @@
+"""Library tag catalog + per-file assignment endpoints (#1268).
+
+Tags are global cross-cutting labels for library files — one catalog per
+install, no per-user partitioning. Designed as the orthogonal complement to
+folders: folders express hierarchy, tags express attributes ("toy",
+"kid-safe", "petg-only"). The reporter (#1268) and at least one upvoter
+asked for them; the design decisions were locked with @maziggy:
+
+* tags apply to files only (folders already express hierarchy)
+* the tag filter on the file list intentionally IGNORES the selected folder
+  so "show me every toy regardless of where it lives" works (multi-tag = AND)
+* bulk-tagging from the multi-select toolbar ships in v1
+* no auto-tags from 3MF metadata; user-authored only
+* no color, no icon — label-only chips
+
+Permission model:
+
+* **Catalog mutations** (POST / PATCH / DELETE on ``/library/tags``) require
+  :attr:`Permission.LIBRARY_UPDATE_ALL` because the catalog is global —
+  ownership-aware update isn't meaningful for a row no user owns.
+* **Bulk assignment** is gated by the existing
+  :attr:`Permission.LIBRARY_UPDATE_ALL` / :attr:`Permission.LIBRARY_UPDATE_OWN`
+  pair so a ``*_OWN`` user can only re-tag files they created.
+* **GET** is gated by :attr:`Permission.LIBRARY_READ_ALL` /
+  :attr:`Permission.LIBRARY_READ_OWN` — ``*_OWN`` callers see every catalog
+  row (it's just labels), but ``file_count`` is filtered to their own files.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete, distinct, func, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission, require_permission_if_auth_enabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import LibraryFile, LibraryFileTag, LibraryTag
+from backend.app.models.user import User
+from backend.app.schemas.library import (
+    TagBulkAssignRequest,
+    TagBulkAssignResponse,
+    TagCreate,
+    TagResponse,
+    TagUpdate,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library/tags", tags=["library-tags"])
+
+
+def _name_key(name: str) -> str:
+    """Case-insensitive uniqueness key — LOWER(TRIM(name)).
+
+    Mirrors the same convention used by Locations (#1505) so the catalog
+    can't end up with "Toys" + "toys" + " TOYS " as separate rows. Empty
+    string after stripping is rejected by Pydantic min_length, so this
+    helper trusts its input.
+    """
+    return name.strip().lower()
+
+
+@router.get("", response_model=list[TagResponse])
+@router.get("/", response_model=list[TagResponse])
+async def list_tags(
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_READ_ALL,
+            Permission.LIBRARY_READ_OWN,
+        )
+    ),
+) -> list[TagResponse]:
+    """List every tag in the catalog with the count of files using it.
+
+    Catalog rows are global, so a ``read_own`` caller still sees every tag
+    name — that's just the chip set the rest of the UI offers. But the
+    ``file_count`` projection is filtered to their own files so the number
+    matches what they'd see when they filter the listing by that tag.
+    """
+    user, can_read_all = auth_result
+
+    # Count distinct file_ids per tag via the association table joined back
+    # to LibraryFile so soft-deleted (trashed) files don't inflate the chip
+    # counts shown in the management modal.
+    file_filter = LibraryFile.deleted_at.is_(None)
+    if user is not None and not can_read_all:
+        file_filter = file_filter & (LibraryFile.created_by_id == user.id)
+
+    count_subq = (
+        select(
+            LibraryFileTag.tag_id.label("tag_id"),
+            func.count(distinct(LibraryFile.id)).label("file_count"),
+        )
+        .join(LibraryFile, LibraryFile.id == LibraryFileTag.file_id)
+        .where(file_filter)
+        .group_by(LibraryFileTag.tag_id)
+        .subquery()
+    )
+
+    query = (
+        select(LibraryTag, func.coalesce(count_subq.c.file_count, 0))
+        .outerjoin(count_subq, count_subq.c.tag_id == LibraryTag.id)
+        .order_by(func.lower(LibraryTag.name))
+    )
+    rows = (await db.execute(query)).all()
+    return [
+        TagResponse(
+            id=t.id,
+            name=t.name,
+            file_count=int(count),
+            created_at=t.created_at,
+            updated_at=t.updated_at,
+        )
+        for t, count in rows
+    ]
+
+
+@router.post("", response_model=TagResponse, status_code=201)
+@router.post("/", response_model=TagResponse, status_code=201)
+async def create_tag(
+    payload: TagCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> TagResponse:
+    """Create a tag. Case-insensitive dup → 409."""
+    key = _name_key(payload.name)
+    tag = LibraryTag(name=payload.name.strip(), name_key=key)
+    db.add(tag)
+    try:
+        await db.commit()
+    except IntegrityError:
+        # Race condition or actual dup — re-fetch the existing row so the
+        # caller can recover by reading the id from the 409 detail string
+        # if they want to. The body is consistent regardless of cause.
+        await db.rollback()
+        raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
+    await db.refresh(tag)
+    return TagResponse(id=tag.id, name=tag.name, file_count=0, created_at=tag.created_at, updated_at=tag.updated_at)
+
+
+@router.patch("/{tag_id}", response_model=TagResponse)
+async def update_tag(
+    tag_id: int,
+    payload: TagUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> TagResponse:
+    """Rename a tag. Case-insensitive dup → 409 (own-name no-op is allowed)."""
+    tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
+    if tag is None:
+        raise HTTPException(status_code=404, detail="Tag not found")
+
+    new_key = _name_key(payload.name)
+    if new_key != tag.name_key:
+        # Pre-check so the user gets a clean 409 instead of an IntegrityError
+        # that we'd then have to translate. The post-commit IntegrityError
+        # branch still catches the concurrent-create race.
+        existing = (await db.execute(select(LibraryTag).where(LibraryTag.name_key == new_key))).scalar_one_or_none()
+        if existing is not None and existing.id != tag.id:
+            raise HTTPException(status_code=409, detail="Tag with this name already exists")
+    tag.name = payload.name.strip()
+    tag.name_key = new_key
+    try:
+        await db.commit()
+    except IntegrityError:
+        await db.rollback()
+        raise HTTPException(status_code=409, detail="Tag with this name already exists") from None
+    await db.refresh(tag)
+
+    # Re-count files for the projection so the caller's modal shows the
+    # right number after the rename.
+    file_count = (
+        await db.execute(select(func.count(LibraryFileTag.file_id)).where(LibraryFileTag.tag_id == tag.id))
+    ).scalar_one()
+    return TagResponse(
+        id=tag.id,
+        name=tag.name,
+        file_count=int(file_count or 0),
+        created_at=tag.created_at,
+        updated_at=tag.updated_at,
+    )
+
+
+@router.delete("/{tag_id}", status_code=204)
+async def delete_tag(
+    tag_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_UPDATE_ALL)),
+) -> None:
+    """Delete a tag. Association rows ON DELETE CASCADE — files are untouched."""
+    tag = (await db.execute(select(LibraryTag).where(LibraryTag.id == tag_id))).scalar_one_or_none()
+    if tag is None:
+        raise HTTPException(status_code=404, detail="Tag not found")
+    await db.delete(tag)
+    await db.commit()
+
+
+@router.post("/bulk-assign", response_model=TagBulkAssignResponse)
+async def bulk_assign(
+    payload: TagBulkAssignRequest,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> TagBulkAssignResponse:
+    """Add / remove / replace tag assignments across multiple files.
+
+    Implemented as set-style operations against the association table —
+    cheaper than re-doing the M2M list per file and idempotent on retries.
+    A caller without ``*_UPDATE_ALL`` can only modify files they created
+    (per the existing ownership pair); silently-skipped files are
+    excluded from the response counts so the UI can detect partial
+    application.
+    """
+    user, can_update_all = auth_result
+
+    # Resolve the file scope FIRST — anything not visible to the caller is
+    # quietly dropped, so a malicious or buggy client can't tag files it
+    # doesn't own. This is the same posture as bulk-delete in
+    # library_trash.py.
+    file_q = select(LibraryFile.id).where(
+        LibraryFile.id.in_(payload.file_ids),
+        LibraryFile.deleted_at.is_(None),
+    )
+    if user is not None and not can_update_all:
+        file_q = file_q.where(LibraryFile.created_by_id == user.id)
+    file_ids = list((await db.execute(file_q)).scalars().all())
+    if not file_ids:
+        return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+
+    # Validate tag ids exist. Unknown tag_ids are silently dropped from
+    # the operation rather than raising — matches the bulk-trash shape
+    # and keeps a partial-success result usable.
+    tag_ids: list[int] = []
+    if payload.tag_ids:
+        tag_ids = list(
+            (await db.execute(select(LibraryTag.id).where(LibraryTag.id.in_(payload.tag_ids)))).scalars().all()
+        )
+
+    added = 0
+    removed = 0
+
+    if payload.action == "add":
+        if not tag_ids:
+            return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+        # Insert (file_id, tag_id) for every pair that doesn't already exist.
+        # We could use INSERT ... ON CONFLICT DO NOTHING for Postgres + SQLite
+        # 3.24+ but the explicit pre-check keeps the SQLAlchemy core dialect
+        # neutral and lets us count what actually got added.
+        existing = set(
+            (
+                await db.execute(
+                    select(LibraryFileTag.file_id, LibraryFileTag.tag_id).where(
+                        LibraryFileTag.file_id.in_(file_ids),
+                        LibraryFileTag.tag_id.in_(tag_ids),
+                    )
+                )
+            ).all()
+        )
+        to_insert = [
+            {"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids if (fid, tid) not in existing
+        ]
+        if to_insert:
+            await db.execute(LibraryFileTag.__table__.insert(), to_insert)
+            added = len(to_insert)
+    elif payload.action == "remove":
+        if not tag_ids:
+            return TagBulkAssignResponse(files_updated=0, associations_added=0, associations_removed=0)
+        result = await db.execute(
+            delete(LibraryFileTag).where(
+                LibraryFileTag.file_id.in_(file_ids),
+                LibraryFileTag.tag_id.in_(tag_ids),
+            )
+        )
+        removed = int(result.rowcount or 0)
+    elif payload.action == "replace":
+        # Strip everything currently on these files, then INSERT the new set.
+        del_result = await db.execute(delete(LibraryFileTag).where(LibraryFileTag.file_id.in_(file_ids)))
+        removed = int(del_result.rowcount or 0)
+        if tag_ids:
+            await db.execute(
+                LibraryFileTag.__table__.insert(),
+                [{"file_id": fid, "tag_id": tid} for fid in file_ids for tid in tag_ids],
+            )
+            added = len(file_ids) * len(tag_ids)
+
+    await db.commit()
+    return TagBulkAssignResponse(
+        files_updated=len(file_ids),
+        associations_added=added,
+        associations_removed=removed,
+    )

+ 25 - 6
backend/app/api/routes/makerworld.py

@@ -21,7 +21,7 @@ from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.api.routes.cloud import get_stored_token
+from backend.app.api.routes.cloud import get_stored_token, resolve_api_key_cloud_owner
 from backend.app.api.routes.library import save_3mf_bytes_to_library
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
@@ -143,9 +143,18 @@ async def proxy_thumbnail(
 async def get_status(
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
-    """Report whether the caller can import 3MFs (needs a Bambu Cloud token)."""
-    token, _email, _region = await get_stored_token(db, current_user)
+    """Report whether the caller can import 3MFs (needs a Bambu Cloud token).
+
+    API-keyed callers (which return None from ``current_user``) get the
+    owner User via ``resolve_api_key_cloud_owner`` when the key carries the
+    cloud-access scope, so ``has_cloud_token`` reflects the owning user's
+    stored token rather than always reporting ``False`` (#1777, same shape
+    as the cloud-presets fix in #1182).
+    """
+    cloud_token_user = current_user or api_key_cloud_owner
+    token, _email, _region = await get_stored_token(db, cloud_token_user)
     has_token = bool(token)
     return MakerWorldStatus(has_cloud_token=has_token, can_download=has_token)
 
@@ -155,6 +164,7 @@ async def resolve_url(
     body: MakerWorldResolveRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_VIEW),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
     """Resolve a MakerWorld URL to full model metadata + plate list.
 
@@ -167,7 +177,10 @@ async def resolve_url(
     except MakerWorldError as exc:
         raise _map_service_error(exc) from exc
 
-    service = await _build_service(db, current_user)
+    # API-keyed callers carry identity on the key, not in current_user — see
+    # the /status handler comment and #1777 / #1182.
+    cloud_token_user = current_user or api_key_cloud_owner
+    service = await _build_service(db, cloud_token_user)
     try:
         design = await service.get_design(model_id)
         instances_envelope = await service.get_design_instances(model_id)
@@ -240,6 +253,7 @@ async def import_instance(
     body: MakerWorldImportRequest,
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.MAKERWORLD_IMPORT),
+    api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner),
 ):
     """Download a specific MakerWorld instance (plate configuration) and save
     the 3MF into the library.
@@ -278,7 +292,12 @@ async def import_instance(
             await db.flush()
         effective_folder_id = mw_folder.id
 
-    service = await _build_service(db, current_user)
+    # API-keyed callers carry identity on the key, not in current_user — see
+    # the /status handler comment and #1777 / #1182. The same resolved user
+    # is reused for owner_id on save_3mf_bytes_to_library below so the
+    # library row is attributed to the key's owner rather than NULL.
+    cloud_token_user = current_user or api_key_cloud_owner
+    service = await _build_service(db, cloud_token_user)
 
     # YASTL#51's iot-service endpoint needs the *alphanumeric* modelId
     # (e.g. "US2bb73b106683e5"), not the integer design id from /models/{N}.
@@ -387,7 +406,7 @@ async def import_instance(
         folder_id=effective_folder_id,
         source_type=_SOURCE_TYPE,
         source_url=source_url,
-        owner_id=current_user.id if current_user else None,
+        owner_id=cloud_token_user.id if cloud_token_user else None,
     )
 
     return MakerWorldImportResponse(

+ 24 - 8
backend/app/api/routes/mfa.py

@@ -34,20 +34,20 @@ from fastapi import APIRouter, Body, Depends, Header, HTTPException, Query, Requ
 from fastapi.responses import RedirectResponse
 from jwt import PyJWKClient
 from passlib.context import CryptContext
-from sqlalchemy import delete, select
+from sqlalchemy import delete, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload, undefer
 
 from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
 from backend.app.api.routes.settings import get_setting, set_setting
 from backend.app.core.auth import (
-    ACCESS_TOKEN_EXPIRE_MINUTES,
     RequirePermissionIfAuthEnabled,
     create_access_token,
     get_current_active_user,
     get_user_by_email,
     get_user_by_username,
     is_auth_enabled,
+    resolve_session_max_minutes,
     verify_password,
 )
 from backend.app.core.database import get_db
@@ -467,7 +467,7 @@ def _enforce_auto_link_safety(provider: OIDCProvider) -> None:
     """
     if provider.auto_link_existing_accounts and provider.email_claim == "email" and not provider.require_email_verified:
         raise HTTPException(
-            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+            status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
             detail=AUTO_LINK_REQUIREMENTS_ERROR,
         )
 
@@ -1242,7 +1242,7 @@ async def verify_2fa(
 
         access_token = create_access_token(
             data={"sub": user.username},
-            expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+            expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
         )
         result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
         user = result.scalar_one()
@@ -1258,7 +1258,7 @@ async def verify_2fa(
 
     access_token = create_access_token(
         data={"sub": user.username},
-        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
     )
 
     # Reload with groups for permission calculation
@@ -1361,7 +1361,7 @@ async def create_oidc_provider(
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
         if not grp_chk.scalar_one_or_none():
             raise HTTPException(
-                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                 detail="default_group_id references a non-existent group",
             )
 
@@ -1388,11 +1388,17 @@ async def create_oidc_provider(
         icon_content_type=icon_content_type,
         icon_etag=icon_etag,
         default_group_id=body.default_group_id,
+        is_autologin=body.is_autologin,
     )
     # SEC-1 + SEC-6: runtime guard mirrors the OIDCProviderCreate model_validator in schemas/auth.py.
     # Catches any future path that bypasses Pydantic validation (direct ORM, scripts).
     _enforce_auto_link_safety(provider)
     db.add(provider)
+    # #1589: at most one provider may be the autologin target. When a new one
+    # is created with the flag set, clear it on all others first so the
+    # session still satisfies the invariant after add.
+    if body.is_autologin:
+        await db.execute(update(OIDCProvider).where(OIDCProvider.is_autologin.is_(True)).values(is_autologin=False))
     await db.commit()
     await db.refresh(provider)
     return _build_provider_response(provider)
@@ -1425,7 +1431,7 @@ async def update_oidc_provider(
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
         if not grp_chk.scalar_one_or_none():
             raise HTTPException(
-                status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
+                status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
                 detail="default_group_id references a non-existent group",
             )
 
@@ -1471,6 +1477,16 @@ async def update_oidc_provider(
     # partial updates that each pass schema validation individually but are unsafe together.
     _enforce_auto_link_safety(provider)
 
+    # #1589: at most one provider may be the autologin target. Clear the flag
+    # on every other provider when this one becomes the autologin. Excludes
+    # the current row so SQLAlchemy doesn't fight our in-memory set above.
+    if body.is_autologin is True:
+        await db.execute(
+            update(OIDCProvider)
+            .where(OIDCProvider.id != provider.id, OIDCProvider.is_autologin.is_(True))
+            .values(is_autologin=False)
+        )
+
     await db.commit()
     await db.refresh(provider)
     return _build_provider_response(provider)
@@ -2146,7 +2162,7 @@ async def oidc_exchange(
 
     access_token = create_access_token(
         data={"sub": user.username},
-        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
+        expires_delta=timedelta(minutes=await resolve_session_max_minutes(db)),
     )
 
     return LoginResponse(

+ 2 - 0
backend/app/api/routes/notifications.py

@@ -47,6 +47,7 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         # Printer status events
         "on_printer_offline": provider.on_printer_offline,
         "on_printer_error": provider.on_printer_error,
+        "on_ai_failure_detection": provider.on_ai_failure_detection,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
         # AMS environmental alarms (regular AMS)
@@ -127,6 +128,7 @@ async def create_notification_provider(
         # Printer status events
         on_printer_offline=provider_data.on_printer_offline,
         on_printer_error=provider_data.on_printer_error,
+        on_ai_failure_detection=provider_data.on_ai_failure_detection,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
         # AMS environmental alarms (regular AMS)

+ 333 - 69
backend/app/api/routes/print_queue.py

@@ -8,7 +8,7 @@ from pathlib import Path
 
 import defusedxml.ElementTree as ET
 from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import and_, func, or_, select
+from sqlalchemy import and_, func, or_, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
 
@@ -25,7 +25,9 @@ from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
+    PrintBatchCreate,
     PrintBatchResponse,
+    PrintBatchUngroupResponse,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
@@ -36,7 +38,11 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
 from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
-from backend.app.utils.threemf_tools import extract_bed_type_from_3mf, extract_filament_usage_from_3mf
+from backend.app.utils.threemf_tools import (
+    extract_bed_type_from_3mf,
+    extract_filament_usage_from_3mf,
+    extract_print_time_from_3mf,
+)
 
 logger = logging.getLogger(__name__)
 
@@ -104,56 +110,10 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
     return sorted(types)
 
 
-def _extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
-    """Extract print time (prediction) from a 3MF file.
-
-    Args:
-        file_path: Path to the 3MF file
-        plate_id: Optional plate index to filter for (for multi-plate files)
-
-    Returns:
-        Print time in seconds, or None if not found
-    """
-    try:
-        with zipfile.ZipFile(file_path, "r") as zf:
-            if "Metadata/slice_info.config" not in zf.namelist():
-                return None
-
-            content = zf.read("Metadata/slice_info.config").decode()
-            root = ET.fromstring(content)
-
-            if plate_id is not None:
-                for plate_elem in root.findall(".//plate"):
-                    plate_index = None
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "index":
-                            try:
-                                plate_index = int(meta.get("value", "0"))
-                            except ValueError:
-                                pass  # Skip plate with unparseable index
-                            break
-
-                    if plate_index == plate_id:
-                        for meta in plate_elem.findall("metadata"):
-                            if meta.get("key") == "prediction":
-                                try:
-                                    return int(meta.get("value", "0"))
-                                except ValueError:
-                                    return None
-                        break
-            else:
-                plate_elem = root.find(".//plate")
-                if plate_elem is not None:
-                    for meta in plate_elem.findall("metadata"):
-                        if meta.get("key") == "prediction":
-                            try:
-                                return int(meta.get("value", "0"))
-                            except ValueError:
-                                return None
-    except Exception as e:
-        logger.warning("Failed to extract print time from %s: %s", file_path, e)
-
-    return None
+# Local alias kept so existing call sites stay compact; the implementation lives
+# in utils/threemf_tools.py so the notification path (main.py) can reuse it
+# without importing from a routes module (#1785).
+_extract_print_time_from_3mf = extract_print_time_from_3mf
 
 
 def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
@@ -182,6 +142,24 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         except json.JSONDecodeError:
             filament_overrides_parsed = None
 
+    # Parse nozzle_mapping from JSON string (#1780 — H2C rack slicer-pick
+    # preservation). Nullable opaque JSON blob stored verbatim from
+    # BambuStudio's project_file; surface it parsed for the response model
+    # and any future "edit print → nozzle" UI.
+    nozzle_mapping_parsed = None
+    if item.nozzle_mapping:
+        try:
+            nozzle_mapping_parsed = json.loads(item.nozzle_mapping)
+        except json.JSONDecodeError:
+            nozzle_mapping_parsed = None
+
+    nozzles_info_parsed = None
+    if item.nozzles_info:
+        try:
+            nozzles_info_parsed = json.loads(item.nozzles_info)
+        except json.JSONDecodeError:
+            nozzles_info_parsed = None
+
     # Create response with parsed ams_mapping
     item_dict = {
         "id": item.id,
@@ -224,6 +202,10 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "been_jumped": item.been_jumped,
         # Auto-print G-code injection
         "gcode_injection": item.gcode_injection,
+        # H2C rack-swap nozzle pick (#1780)
+        "nozzle_mapping": nozzle_mapping_parsed,
+        "nozzles_info": nozzles_info_parsed,
+        "cleanup_library_after_dispatch": item.cleanup_library_after_dispatch,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -424,6 +406,23 @@ async def add_to_queue(
             and archive.created_by_id != current_user.id
         ):
             raise HTTPException(404, "Archive not found")
+        # Reprint perm gate (#1625): the legacy /archives/{id}/reprint endpoint
+        # required ARCHIVES_REPRINT_OWN/ALL; the unified queue route must keep
+        # that gate or an operator with QUEUE_CREATE could reprint via direct
+        # API call even if explicitly denied reprint perm. Mirrors the
+        # frontend `canModify('archives', 'reprint', ...)` helper:
+        # REPRINT_ALL allows any archive, REPRINT_OWN allows own only,
+        # ownerless archives require REPRINT_ALL (fail-closed).
+        if current_user is not None:
+            owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
+            has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
+                owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
+            )
+            if not has_reprint:
+                raise HTTPException(
+                    status_code=403,
+                    detail="Permission archives:reprint_own or archives:reprint_all required",
+                )
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
@@ -482,10 +481,30 @@ async def add_to_queue(
     # Validate quantity
     quantity = max(1, data.quantity)
 
-    # Create batch if quantity > 1
+    # Validate batch_id if provided. Client passes batch_id when adding items
+    # into a pre-created batch (multi-plate auto-batch or "Group as batch" flow).
+    # 404 keeps the existing-id leak surface low.
     batch = None
     batch_id = None
-    if quantity > 1:
+    if data.batch_id is not None:
+        result = await db.execute(select(PrintBatch).where(PrintBatch.id == data.batch_id))
+        existing_batch = result.scalar_one_or_none()
+        if not existing_batch:
+            raise HTTPException(404, "Batch not found")
+        if existing_batch.status != "active":
+            raise HTTPException(400, "Cannot add items to a non-active batch")
+        if (
+            current_user is not None
+            and existing_batch.created_by_id is not None
+            and existing_batch.created_by_id != current_user.id
+            and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+        ):
+            raise HTTPException(404, "Batch not found")
+        batch = existing_batch
+        batch_id = existing_batch.id
+
+    # Create batch if quantity > 1 and no batch_id provided
+    if batch_id is None and quantity > 1:
         # Derive batch name from source file
         batch_name_base = "Batch"
         if archive:
@@ -509,21 +528,59 @@ async def add_to_queue(
         await db.flush()  # Get batch.id before creating items
         batch_id = batch.id
 
-    # Get next position for this printer (or for unassigned/model-based items)
+    # Get queue scope for this printer (or for unassigned/model-based items).
     if data.printer_id is not None:
-        result = await db.execute(
-            select(func.max(PrintQueueItem.position))
-            .where(PrintQueueItem.printer_id == data.printer_id)
-            .where(PrintQueueItem.status == "pending")
+        queue_scope = (
+            PrintQueueItem.printer_id == data.printer_id,
+            PrintQueueItem.status == "pending",
         )
     else:
-        # For unassigned/model-based items, get max position across all unassigned
-        result = await db.execute(
-            select(func.max(PrintQueueItem.position))
-            .where(PrintQueueItem.printer_id.is_(None))
-            .where(PrintQueueItem.status == "pending")
+        # For unassigned/model-based items, scope across all unassigned.
+        queue_scope = (
+            PrintQueueItem.printer_id.is_(None),
+            PrintQueueItem.status == "pending",
         )
-    max_pos = result.scalar() or 0
+
+    # Serialize concurrent queue inserts to the same scope (#1625-followup).
+    # The race: two concurrent ASAP inserts both compute MAX(position) before
+    # either commits; in an empty scope, both INSERT at position 1 (duplicate).
+    # In a non-empty scope, Postgres's row-level locks on the UPDATE shift
+    # serialize naturally, but the empty-scope path has no rows to lock.
+    # A transaction-scoped advisory lock keyed on the printer_id closes that
+    # window; the lock is released automatically at commit/rollback. Different
+    # printers don't contend. SQLite serializes writes implicitly so this is a
+    # no-op there.
+    #
+    # Dialect is checked against the actual session binding, NOT the
+    # `is_sqlite()` helper, because the test fixture overrides `get_db` with a
+    # SQLite engine while `settings.database_url` still points at Postgres
+    # (the helper reads settings). Inspecting the connection directly is the
+    # right shape for any code that mutates SQL based on the live dialect.
+    from sqlalchemy import text
+
+    bind = db.get_bind()
+    if bind.dialect.name == "postgresql":
+        scope_key = data.printer_id if data.printer_id is not None else 0
+        # 1625 namespaces the lock so it can't collide with other advisory
+        # locks elsewhere in the codebase.
+        await db.execute(text("SELECT pg_advisory_xact_lock(1625, :k)"), {"k": scope_key})
+
+    insert_position = max(1, data.insert_position or 1)
+    if data.insert_at_top or data.insert_position is not None:
+        result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
+        max_pos = result.scalar() or 0
+        insert_position = min(insert_position, max_pos + 1)
+        await db.execute(
+            update(PrintQueueItem)
+            .where(*queue_scope)
+            .where(PrintQueueItem.position >= insert_position)
+            .values(position=PrintQueueItem.position + quantity)
+        )
+        start_position = insert_position
+    else:
+        result = await db.execute(select(func.max(PrintQueueItem.position)).where(*queue_scope))
+        max_pos = result.scalar() or 0
+        start_position = max_pos + 1
 
     # Resolve print_time_seconds for SJF scheduling (cache on item at creation)
     cached_print_time = None
@@ -578,8 +635,9 @@ async def add_to_queue(
             use_ams=data.use_ams,
             nozzle_offset_cali=data.nozzle_offset_cali,
             gcode_injection=data.gcode_injection,
+            cleanup_library_after_dispatch=data.cleanup_library_after_dispatch,
             project_id=data.project_id,
-            position=max_pos + 1 + i,
+            position=start_position + i,
             status="pending",
             created_by_id=current_user.id if current_user else None,
             batch_id=batch_id,
@@ -708,6 +766,106 @@ async def bulk_update_queue_items(
 # --- Batch endpoints ---
 
 
+@router.post("/batches", response_model=PrintBatchResponse)
+async def create_batch(
+    data: PrintBatchCreate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+):
+    """Create a batch.
+
+    Two modes:
+    * ``item_ids`` provided: assign the listed pending queue items to a new
+      batch ("Group as batch" UI action).
+    * ``item_ids`` omitted/empty: create an empty batch so the client can
+      pass the returned ``id`` on subsequent ``POST /queue/`` calls. Used by
+      the multi-plate auto-batch flow in PrintModal.
+    """
+    if not data.name or not data.name.strip():
+        raise HTTPException(400, "Batch name is required")
+
+    batch = PrintBatch(
+        name=data.name.strip()[:255],
+        archive_id=data.archive_id,
+        library_file_id=data.library_file_id,
+        quantity=len(data.item_ids) if data.item_ids else 1,
+        status="active",
+        created_by_id=current_user.id if current_user else None,
+    )
+    db.add(batch)
+    await db.flush()  # Need batch.id before assigning to items
+
+    assigned = 0
+    if data.item_ids:
+        result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
+        items = result.scalars().all()
+        for item in items:
+            if item.status != "pending":
+                continue
+            if item.batch_id is not None:
+                continue
+            if (
+                current_user is not None
+                and item.created_by_id != current_user.id
+                and not current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+            ):
+                continue
+            item.batch_id = batch.id
+            assigned += 1
+        batch.quantity = max(assigned, 1)
+
+    await db.commit()
+    await db.refresh(batch)
+
+    logger.info("Created batch %s '%s' with %s assigned items", batch.id, batch.name, assigned)
+    return await _build_batch_response(db, batch)
+
+
+@router.post("/batches/{batch_id}/ungroup", response_model=PrintBatchUngroupResponse)
+async def ungroup_batch(
+    batch_id: int,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+):
+    """Disband a batch: clear batch_id from all members and delete the batch row.
+
+    Items stay in the queue. Only ungroups items the caller owns (unless they
+    hold QUEUE_UPDATE_ALL). A batch with all members ungrouped is deleted.
+    """
+    result = await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+
+    can_modify_all = current_user is None or current_user.has_permission(Permission.QUEUE_UPDATE_ALL.value)
+    if not can_modify_all and batch.created_by_id != (current_user.id if current_user else None):
+        raise HTTPException(404, "Batch not found")
+
+    result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id))
+    items = result.scalars().all()
+    ungrouped = 0
+    remaining = 0
+    for item in items:
+        if not can_modify_all and item.created_by_id != (current_user.id if current_user else None):
+            remaining += 1
+            continue
+        item.batch_id = None
+        ungrouped += 1
+
+    # Delete the batch row only when all members were ungrouped — otherwise it
+    # still owns the items the caller couldn't touch.
+    if remaining == 0:
+        await db.delete(batch)
+
+    await db.commit()
+
+    logger.info("Ungrouped batch %s (%s items)", batch_id, ungrouped)
+    return PrintBatchUngroupResponse(
+        ungrouped_count=ungrouped,
+        message=f"Ungrouped {ungrouped} item(s)",
+    )
+
+
 @router.get("/batches", response_model=list[PrintBatchResponse])
 async def list_batches(
     status: str | None = Query(None, description="Filter by status (active, completed, cancelled)"),
@@ -929,6 +1087,13 @@ async def update_queue_item(
             json.dumps(update_data["filament_overrides"]) if update_data["filament_overrides"] else None
         )
 
+    # Serialize H2C rack-swap nozzle pick (#1780) to JSON for TEXT column
+    # storage; same Text-as-opaque-blob convention as ams_mapping above.
+    if "nozzle_mapping" in update_data:
+        update_data["nozzle_mapping"] = (
+            json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
+        )
+
     for field, value in update_data.items():
         setattr(item, field, value)
 
@@ -991,6 +1156,64 @@ async def reorder_queue(
     return {"message": f"Reordered {len(data.items)} items"}
 
 
+@router.post("/printer/{printer_id}/resume")
+async def resume_queue_after_failure(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+):
+    """Clear the previous-success gate for a printer and restore skipped items.
+
+    Single atomic op (#1818):
+
+    * Sets ``gate_acknowledged=True`` on every ``failed`` / ``aborted`` queue
+      item for this printer that's still in the scheduler's lookback window,
+      so the next ``_check_previous_success`` call ignores them.
+    * Restores ``skipped`` items whose ``error_message`` matches the
+      scheduler's exact "Previous print failed or was aborted" gate string
+      back to ``pending`` (clears ``error_message`` + ``completed_at``).
+
+    Returns counts so the UI can render a precise toast. No-op endpoint
+    (zero counts) when called against a printer with no gate to clear.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    ack_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.printer_id == printer_id)
+        .where(PrintQueueItem.status.in_(["failed", "aborted"]))
+        .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
+    )
+    to_ack = ack_result.scalars().all()
+    for failed_item in to_ack:
+        failed_item.gate_acknowledged = True
+
+    restore_result = await db.execute(
+        select(PrintQueueItem)
+        .where(PrintQueueItem.printer_id == printer_id)
+        .where(PrintQueueItem.status == "skipped")
+        .where(PrintQueueItem.error_message == "Previous print failed or was aborted")
+    )
+    to_restore = restore_result.scalars().all()
+    for skipped_item in to_restore:
+        skipped_item.status = "pending"
+        skipped_item.error_message = None
+        skipped_item.completed_at = None
+
+    await db.commit()
+
+    logger.info(
+        "Resume after failure on printer %s: acknowledged %d failure(s), restored %d skipped item(s)",
+        printer_id,
+        len(to_ack),
+        len(to_restore),
+    )
+    return {"acknowledged": len(to_ack), "restored": len(to_restore)}
+
+
 @router.post("/{item_id}/cancel")
 async def cancel_queue_item(
     item_id: int,
@@ -1030,19 +1253,39 @@ async def cancel_queue_item(
 async def stop_queue_item(
     item_id: int,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_ALL),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
-    """Stop an actively printing queue item."""
+    """Stop an actively printing queue item.
+
+    Ownership-scoped (#1625-followup): callers with QUEUE_UPDATE_OWN can stop
+    their own items; callers with QUEUE_UPDATE_ALL can stop any item. Mirrors
+    the /cancel shape. Pre-fix this required QUEUE_UPDATE_ALL — Operators
+    holding only _OWN saw the Stop button in the queue UI but got 403 on click.
+    """
 
     from backend.app.models.smart_plug import SmartPlug
     from backend.app.services.printer_manager import printer_manager
     from backend.app.services.tasmota import tasmota_service
 
+    user, can_modify_all = auth_result
+
     result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
     item = result.scalar_one_or_none()
     if not item:
         raise HTTPException(404, "Queue item not found")
 
+    # Ownership check — mirrors /cancel. Ownerless items (created_by_id IS NULL)
+    # require _ALL: stop is destructive and an _OWN holder can't claim "they
+    # own it" the way /start does (#1670).
+    if not can_modify_all and user is not None:
+        if item.created_by_id is None or item.created_by_id != user.id:
+            raise HTTPException(403, "You can only stop your own queue items")
+
     if item.status != "printing":
         raise HTTPException(400, f"Can only stop items that are printing, current status: '{item.status}'")
 
@@ -1112,10 +1355,21 @@ async def start_queue_item(
     item_id: int,
     skip_filament_check: bool = Query(default=False),
     db: AsyncSession = Depends(get_db),
-    user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.QUEUE_UPDATE_ALL,
+            Permission.QUEUE_UPDATE_OWN,
+        )
+    ),
 ):
     """Manually start a staged (manual_start) queue item.
 
+    Ownership-scoped (#1625-followup): callers with QUEUE_UPDATE_OWN can
+    start their own items + claim ownership of NULL-owner items (VP-uploaded
+    items arrive unattributed per #1670). Callers with QUEUE_UPDATE_ALL can
+    start any item. Pre-fix this required QUEUE_UPDATE_OWN with no ownership
+    check, so _OWN holders could start anyone's queue items via direct API.
+
     Clears the manual_start flag so the scheduler picks it up. When
     ``skip_filament_check`` is false (the default) the live filament
     deficit (#1496) is checked first — if the assigned spool can't satisfy
@@ -1123,6 +1377,8 @@ async def start_queue_item(
     payload so the caller can show a confirm dialog and retry with
     ``skip_filament_check=true``.
     """
+    user, can_modify_all = auth_result
+
     result = await db.execute(
         select(PrintQueueItem)
         .options(
@@ -1137,6 +1393,14 @@ async def start_queue_item(
     if not item:
         raise HTTPException(404, "Queue item not found")
 
+    # Ownership check — softer than /cancel because /start is the entry point
+    # for #1670's VP-import flow: an unowned item is claimable by the first
+    # _OWN holder who clicks ▶, and the route below credits them as owner.
+    # An item with a DIFFERENT owner → 403.
+    if not can_modify_all and user is not None:
+        if item.created_by_id is not None and item.created_by_id != user.id:
+            raise HTTPException(403, "You can only start your own queue items")
+
     if item.status != "pending":
         raise HTTPException(400, f"Can only start pending items, current status: '{item.status}'")
 

+ 140 - 0
backend/app/api/routes/printer_sensor_history.py

@@ -0,0 +1,140 @@
+"""API routes for printer heater (nozzle / bed / chamber) sensor history."""
+
+from datetime import datetime, timedelta, timezone
+
+from fastapi import APIRouter, Depends, Query
+from pydantic import BaseModel
+from sqlalchemy import and_, func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.printer_sensor_history import PrinterSensorHistory
+from backend.app.models.user import User
+
+router = APIRouter(prefix="/printer-sensor-history", tags=["printer-sensor-history"])
+
+VALID_KINDS = {"nozzle", "nozzle_2", "bed", "chamber"}
+
+
+class HeaterHistoryPoint(BaseModel):
+    recorded_at: datetime
+    value: float | None
+    target: float | None
+
+
+class HeaterSeries(BaseModel):
+    sensor_kind: str
+    data: list[HeaterHistoryPoint]
+    min_value: float | None
+    max_value: float | None
+    avg_value: float | None
+
+
+class PrinterSensorHistoryResponse(BaseModel):
+    printer_id: int
+    series: list[HeaterSeries]
+
+
+@router.get("/{printer_id}", response_model=PrinterSensorHistoryResponse)
+async def get_printer_sensor_history(
+    printer_id: int,
+    hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
+    kinds: str | None = Query(
+        default=None,
+        description="Comma-separated list of sensor kinds (nozzle, nozzle_2, bed, chamber). All by default.",
+    ),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+):
+    """Return per-sensor heater history for a printer."""
+    since = datetime.now(timezone.utc) - timedelta(hours=hours)
+
+    if kinds:
+        requested = {k.strip() for k in kinds.split(",") if k.strip()}
+        kinds_to_fetch = sorted(requested & VALID_KINDS)
+    else:
+        kinds_to_fetch = sorted(VALID_KINDS)
+
+    series_out: list[HeaterSeries] = []
+    for kind in kinds_to_fetch:
+        result = await db.execute(
+            select(PrinterSensorHistory)
+            .where(
+                and_(
+                    PrinterSensorHistory.printer_id == printer_id,
+                    PrinterSensorHistory.sensor_kind == kind,
+                    PrinterSensorHistory.recorded_at >= since,
+                )
+            )
+            .order_by(PrinterSensorHistory.recorded_at)
+        )
+        records = result.scalars().all()
+
+        stats_result = await db.execute(
+            select(
+                func.min(PrinterSensorHistory.value).label("min_v"),
+                func.max(PrinterSensorHistory.value).label("max_v"),
+                func.avg(PrinterSensorHistory.value).label("avg_v"),
+            ).where(
+                and_(
+                    PrinterSensorHistory.printer_id == printer_id,
+                    PrinterSensorHistory.sensor_kind == kind,
+                    PrinterSensorHistory.recorded_at >= since,
+                )
+            )
+        )
+        stats = stats_result.one()
+
+        series_out.append(
+            HeaterSeries(
+                sensor_kind=kind,
+                data=[
+                    HeaterHistoryPoint(
+                        recorded_at=r.recorded_at,
+                        value=r.value,
+                        target=r.target,
+                    )
+                    for r in records
+                ],
+                min_value=stats.min_v,
+                max_value=stats.max_v,
+                avg_value=round(stats.avg_v, 1) if stats.avg_v is not None else None,
+            )
+        )
+
+    return PrinterSensorHistoryResponse(printer_id=printer_id, series=series_out)
+
+
+@router.delete("/{printer_id}")
+async def delete_old_history(
+    printer_id: int,
+    days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTER_SENSOR_HISTORY_READ),
+):
+    """Delete old printer sensor history for a printer."""
+    cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+
+    result = await db.execute(
+        select(func.count(PrinterSensorHistory.id)).where(
+            and_(
+                PrinterSensorHistory.printer_id == printer_id,
+                PrinterSensorHistory.recorded_at < cutoff,
+            )
+        )
+    )
+    count = result.scalar()
+
+    await db.execute(
+        PrinterSensorHistory.__table__.delete().where(
+            and_(
+                PrinterSensorHistory.printer_id == printer_id,
+                PrinterSensorHistory.recorded_at < cutoff,
+            )
+        )
+    )
+    await db.commit()
+
+    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

+ 420 - 11
backend/app/api/routes/printers.py

@@ -8,7 +8,11 @@ from fastapi.responses import Response
 from sqlalchemy import func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
-from backend.app.core.auth import RequireCameraStreamTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
+from backend.app.core.auth import (
+    RequireCameraStreamTokenIfAuthEnabled,
+    RequirePermissionIfAuthEnabled,
+    is_auth_enabled,
+)
 from backend.app.core.config import settings
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
@@ -16,18 +20,21 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.slot_preset import SlotPresetMapping
+from backend.app.models.user import User
 from backend.app.schemas.printer import (
     AmsLabelBody,
     AMSTray,
     AMSUnit,
     DiagnosticRequest,
     FilaSwitchResponse,
+    HmsActionBody,
     HMSErrorResponse,
     NozzleInfoResponse,
     NozzleRackSlot,
     PrinterCreate,
     PrinterDiagnosticResult,
     PrinterResponse,
+    PrinterResponseWithSecret,
     PrinterStatus,
     PrinterUpdate,
     PrintOptionsResponse,
@@ -46,23 +53,66 @@ from backend.app.services.printer_manager import (
     get_derived_status_name,
     printer_manager,
     resolve_plate_id,
+    supports_chamber_heater,
     supports_chamber_temp,
     supports_drying,
+    supports_drying_while_printing,
 )
 from backend.app.utils.http import build_content_disposition
 
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 
+# Seconds the /hms/execute-action route waits for a printer status push
+# confirming the command landed before reporting 502 to the UI. Module-level
+# so tests can monkeypatch a near-zero value instead of mocking asyncio.sleep.
+HMS_ACTION_ACK_WAIT_SECONDS = 2.5
+
+
+async def _caller_can_view_printer_secrets(user: User | None, db: AsyncSession) -> bool:
+    """Whether the caller is trusted enough to see ``access_code`` on a printer
+    response. Fail-CLOSED: anything that isn't an authenticated user holding
+    PRINTERS_UPDATE returns False.
+
+    - Auth disabled  → True (single trust domain — same as today's local UI).
+    - JWT user with PRINTERS_UPDATE → True (Admin or Operator; the same roles
+      that already manage printers and the Virtual Printer card UX that
+      surfaces a target's code for slicer configuration).
+    - JWT Viewer → False (the bug fix: Viewers must not be able to read
+      access_code via PRINTERS_READ and then go around Bambuddy to MQTT).
+    - API-key principal (``user is None`` because the dep returns None for
+      API keys) → False. PRINTERS_UPDATE is admin-only and absent from
+      ``_APIKEY_SCOPE_BY_PERMISSION``, so no API key can hold it.
+    """
+    if not await is_auth_enabled(db):
+        return True
+    if user is None:
+        return False
+    return user.has_permission(Permission.PRINTERS_UPDATE.value)
+
+
+def _serialize_printer(printer: Printer, *, include_secret: bool):
+    """Build the response shape that matches the caller's authority."""
+    if include_secret:
+        return PrinterResponseWithSecret.model_validate(printer)
+    return PrinterResponse.model_validate(printer)
 
-@router.get("/", response_model=list[PrinterResponse])
+
+@router.get("/")
 async def list_printers(
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """List all configured printers."""
+    """List all configured printers.
+
+    ``access_code`` is included in each item only when the caller is trusted
+    to see it (Admin / Operator JWT, or auth-disabled mode). Viewers and
+    API keys never receive it.
+    """
     result = await db.execute(select(Printer).order_by(Printer.name))
-    return list(result.scalars().all())
+    printers = list(result.scalars().all())
+    include_secret = await _caller_can_view_printer_secrets(user, db)
+    return [_serialize_printer(p, include_secret=include_secret) for p in printers]
 
 
 @router.post("/", response_model=PrinterResponse)
@@ -262,18 +312,24 @@ async def get_developer_mode_warnings(
     return warnings
 
 
-@router.get("/{printer_id}", response_model=PrinterResponse)
+@router.get("/{printer_id}")
 async def get_printer(
     printer_id: int,
-    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    user: User | None = RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
     db: AsyncSession = Depends(get_db),
 ):
-    """Get a specific printer."""
+    """Get a specific printer.
+
+    ``access_code`` is included only when the caller is trusted to see it
+    (Admin / Operator JWT, or auth-disabled mode). Viewers and API keys
+    never receive it.
+    """
     result = await db.execute(select(Printer).where(Printer.id == printer_id))
     printer = result.scalar_one_or_none()
     if not printer:
         raise HTTPException(404, "Printer not found")
-    return printer
+    include_secret = await _caller_can_view_printer_secrets(user, db)
+    return _serialize_printer(printer, include_secret=include_secret)
 
 
 @router.patch("/{printer_id}", response_model=PrinterResponse)
@@ -406,7 +462,15 @@ async def get_printer_status(
 
     # Convert HMS errors to response format
     hms_errors = [
-        HMSErrorResponse(code=e.code, attr=e.attr, module=e.module, severity=e.severity)
+        HMSErrorResponse(
+            code=e.code,
+            attr=e.attr,
+            module=e.module,
+            severity=e.severity,
+            actions=e.actions,
+            job_id=e.job_id,
+            full_code=e.full_code,
+        )
         for e in (state.hms_errors or [])
     ]
 
@@ -426,6 +490,11 @@ async def get_printer_status(
             except (ValueError, TypeError):
                 pass  # Skip K-profile entries with unparseable values
 
+    # Cached active-cycle drying params (filament + target temp) we sent
+    # last; Bambu doesn't echo them on the per-tick AMS push, so the badge
+    # needs the cache to render "<filament> @ <temp>°C".
+    drying_targets = printer_manager.get_drying_targets(printer_id) or {}
+
     if "ams" in raw_data and isinstance(raw_data["ams"], list):
         ams_exists = True
         for ams_data in raw_data["ams"]:
@@ -487,9 +556,37 @@ async def get_printer_status(
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
 
+            ams_id_int = int(ams_data.get("id", 0))
+            target = drying_targets.get(ams_id_int) or {}
+            dry_target_temp: int | None = None
+            dry_filament: str | None = None
+            target_temp_val = target.get("temp")
+            target_fil_val = target.get("filament") or ""
+            if target_temp_val is not None:
+                try:
+                    dry_target_temp = int(target_temp_val)
+                except (TypeError, ValueError):
+                    dry_target_temp = None
+            if target_fil_val:
+                dry_filament = str(target_fil_val)
+            # Fallback: derive from first loaded tray when no cached target
+            # (drying started in a previous backend session, or cache wasn't
+            # seeded). Mirrors the popover seed heuristic.
+            if dry_target_temp is None or not dry_filament:
+                for tray in trays:
+                    if tray.tray_type:
+                        if not dry_filament:
+                            dry_filament = str(tray.tray_type)
+                        if dry_target_temp is None and tray.drying_temp:
+                            try:
+                                dry_target_temp = int(tray.drying_temp)
+                            except (TypeError, ValueError):
+                                pass
+                        break
+
             ams_units.append(
                 AMSUnit(
-                    id=ams_data.get("id", 0),
+                    id=ams_id_int,
                     humidity=humidity_value,
                     temp=ams_data.get("temp"),
                     is_ams_ht=is_ams_ht,
@@ -500,6 +597,8 @@ async def get_printer_status(
                     sw_ver=str(ams_data.get("sw_ver") or ""),
                     # Drying: dry_time > 0 means drying is active (minutes remaining)
                     dry_time=int(ams_data.get("dry_time") or 0),
+                    dry_target_temp=dry_target_temp,
+                    dry_filament=dry_filament,
                     module_type=str(ams_data.get("module_type") or ""),
                 )
             )
@@ -675,8 +774,11 @@ async def get_printer_status(
         heatbreak_fan_speed=state.heatbreak_fan_speed,
         firmware_version=state.firmware_version,
         developer_mode=state.developer_mode if state else None,
+        ams_filament_backup=state.ams_filament_backup if state else None,
         awaiting_plate_clear=printer_manager.is_awaiting_plate_clear(printer_id),
         supports_drying=supports_drying(printer.model, state.firmware_version),
+        supports_drying_while_printing=supports_drying_while_printing(printer.model, state.firmware_version),
+        supports_chamber_heater=supports_chamber_heater(printer.model),
         current_archive_id=current_archive_id,
         current_plate_id=current_plate_id,
         fila_switch=(
@@ -1824,6 +1926,56 @@ async def set_print_option(
     }
 
 
+@router.post("/{printer_id}/ams-backup")
+async def set_ams_backup(
+    printer_id: int,
+    enabled: bool,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Toggle AMS Filament Backup (auto-switch to a backup spool when one runs out)."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client or not client.state.connected:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_ams_filament_backup(enabled)
+    if not success:
+        raise HTTPException(500, "Failed to send command to printer")
+
+    return {"success": True, "ams_filament_backup": enabled}
+
+
+@router.get("/{printer_id}/inventory-remain")
+async def get_inventory_remain(
+    printer_id: int,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Per-globalTrayId remaining grams for slots bound to an inventory spool.
+
+    Mirrors `_build_inventory_remain_overrides` server-side so the PrintModal
+    client can apply the same two-tier "Prefer Lowest Remaining Filament" sort
+    the dispatcher uses (#1766). Works for both internal inventory and
+    Spoolman; unbound slots are absent from the map (client falls back to the
+    printer's MQTT `remain` for those).
+    """
+    from backend.app.services.print_scheduler import PrintScheduler
+
+    state = printer_manager.get_status(printer_id)
+    if not state:
+        return {"inventory_remain_g": {}}
+
+    scheduler = PrintScheduler()
+    loaded = scheduler._build_loaded_filaments(state)
+    overrides = await scheduler._build_inventory_remain_overrides(db, printer_id, loaded)
+    return {"inventory_remain_g": {str(k): v for k, v in overrides.items()}}
+
+
 # ============================================
 # Calibration
 # ============================================
@@ -2758,6 +2910,144 @@ async def set_print_speed(
     return {"success": True, "message": f"Print speed set to {speed_names.get(mode, 'Unknown')}"}
 
 
+@router.post("/{printer_id}/temperature/nozzle")
+async def set_nozzle_temperature(
+    printer_id: int,
+    target: int = Query(..., ge=0, le=320, description="Target nozzle temperature in Celsius; 0 turns heating off"),
+    nozzle: int = Query(0, ge=0, le=1, description="Nozzle/extruder index (0=right/default, 1=left)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set a nozzle target temperature."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_nozzle_temperature(target, nozzle)
+    if not success:
+        raise HTTPException(500, "Failed to set nozzle temperature")
+
+    return {"success": True, "message": f"Nozzle temperature set to {target}°C"}
+
+
+@router.post("/{printer_id}/temperature/bed")
+async def set_bed_temperature(
+    printer_id: int,
+    target: int = Query(..., ge=0, le=140, description="Target bed temperature in Celsius; 0 turns heating off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the bed target temperature."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_bed_temperature(target)
+    if not success:
+        raise HTTPException(500, "Failed to set bed temperature")
+
+    return {"success": True, "message": f"Bed temperature set to {target}°C"}
+
+
+@router.post("/{printer_id}/temperature/chamber")
+async def set_chamber_temperature(
+    printer_id: int,
+    target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set the chamber target temperature.
+
+    Gated on `supports_chamber_heater(model)`: only H2C, H2D, H2D Pro, H2S,
+    and X2D have an active chamber heater. Sensor-only models (X1C, X1E,
+    P2S) report chamber temp but silently swallow M141, so we 400 here
+    rather than send a no-op.
+    """
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    if not supports_chamber_heater(printer.model):
+        raise HTTPException(400, f"Model {printer.model or 'unknown'} does not have an active chamber heater")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_chamber_temperature(target)
+    if not success:
+        raise HTTPException(500, "Failed to set chamber temperature")
+
+    return {"success": True, "message": f"Chamber temperature set to {target}°C"}
+
+
+@router.post("/{printer_id}/fan-speed")
+async def set_fan_speed(
+    printer_id: int,
+    fan: str = Query(..., description="Fan to control: part, aux, or chamber"),
+    speed: int = Query(..., ge=0, le=100, description="Fan speed percentage"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Set a fan speed by percentage."""
+    fan_ids = {"part": 1, "aux": 2, "chamber": 3}
+    fan_id = fan_ids.get(fan)
+    if fan_id is None:
+        raise HTTPException(400, "fan must be 'part', 'aux', or 'chamber'")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    pwm_speed = round(speed * 255 / 100)
+    success = client.set_fan_speed(fan_id, pwm_speed)
+    if not success:
+        raise HTTPException(500, "Failed to set fan speed")
+
+    fan_names = {"part": "Part cooling fan", "aux": "Auxiliary fan", "chamber": "Chamber fan"}
+    return {"success": True, "message": f"{fan_names[fan]} set to {speed}%"}
+
+
+@router.post("/{printer_id}/select-extruder")
+async def select_extruder(
+    printer_id: int,
+    extruder: int = Query(..., ge=0, le=1, description="Extruder index (0=right, 1=left)"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Select the active extruder/nozzle on dual-nozzle printers."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.select_extruder(extruder)
+    if not success:
+        raise HTTPException(500, "Failed to select nozzle")
+
+    return {"success": True, "message": f"{'Left' if extruder == 1 else 'Right'} nozzle selected"}
+
+
 @router.post("/{printer_id}/airduct-mode")
 async def set_airduct_mode(
     printer_id: int,
@@ -2870,6 +3160,72 @@ async def bed_jog(
     return {"success": True, "message": f"Bed jog {distance:+.1f} mm sent"}
 
 
+@router.post("/{printer_id}/xy-jog")
+async def xy_jog(
+    printer_id: int,
+    x: float = Query(0, description="Signed relative X movement in mm"),
+    y: float = Query(0, description="Signed relative Y movement in mm"),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Move the toolhead by a relative X/Y distance."""
+    if (x == 0 and y == 0) or abs(x) > 200 or abs(y) > 200:
+        raise HTTPException(400, "X/Y movement must be non-zero and ≤ 200 mm per axis")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    axes = []
+    if x:
+        axes.append(f"X{x:.2f}")
+    if y:
+        axes.append(f"Y{y:.2f}")
+
+    if not client.send_gcode("\n".join(["G91", f"G1 {' '.join(axes)} F6000", "G90"])):
+        raise HTTPException(500, "Failed to send XY jog command")
+
+    return {"success": True, "message": f"XY jog X{x:+.1f} Y{y:+.1f} mm sent"}
+
+
+@router.post("/{printer_id}/extruder-jog")
+async def extruder_jog(
+    printer_id: int,
+    distance: float = Query(
+        ..., description="Signed relative extrusion distance in mm. Positive extrudes, negative retracts."
+    ),
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Extrude or retract filament by a relative distance.
+
+    No client-side cold-extrude guard: Bambu firmware refuses extrusion
+    below its min-extrude temperature, so a cold call is rejected at the
+    printer, not silently damaging the extruder gear.
+    """
+    if distance == 0 or abs(distance) > 100:
+        raise HTTPException(400, "Extruder movement must be non-zero and ≤ 100 mm")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    if not client.send_gcode("\n".join(["M83", f"G1 E{distance:.2f} F300", "M82"])):
+        raise HTTPException(500, "Failed to send extruder jog command")
+
+    return {"success": True, "message": f"Extruder jog {distance:+.1f} mm sent"}
+
+
 @router.post("/{printer_id}/home-axes")
 async def home_axes(
     printer_id: int,
@@ -3445,3 +3801,56 @@ async def get_runtime_debug(
         else None,
         "is_active": printer.is_active,
     }
+
+
+@router.post("/{printer_id}/hms/execute-action")
+async def execute_hms_action(
+    printer_id: int,
+    body: HmsActionBody,
+    _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
+    db: AsyncSession = Depends(get_db),
+):
+    """Execute an HMS action on the printer."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    # Snapshot pre-state so we can verify the printer actually acted on the
+    # command. publish() success is NOT the same as printer-ack: Bambu's
+    # firmware silently rejects malformed HMS commands at QoS 1 (the broker
+    # ACKs the publish, but the printer drops it). Verified end-to-end against
+    # a live H2D — see #1830 §(3). We sample (gcode_state, hms_errors length)
+    # because every accepted HMS action mutates at least one of them.
+    #
+    # PrinterState.state carries the MQTT `gcode_state` value verbatim (see
+    # bambu_mqtt.py line 2144); the raw `print_error` int isn't preserved on
+    # state, only the derived HMSError entries are.
+    pre_gcode = client.state.state
+    pre_hms_count = len(client.state.hms_errors)
+
+    success = client.execute_hms_action(body.print_error, body.action, body.job_id)
+    if not success:
+        raise HTTPException(400, "Failed to execute HMS action")
+
+    # Give the printer time to push a state update. The dispatch helper already
+    # publishes a pushall after every command, so a fresh status should arrive
+    # within ~1s; the default 2.5s covers slower firmware variants without
+    # making the UI feel hung. Plain sleep is fine — paho's MQTT callback
+    # runs in its own thread and updates state regardless of whether this
+    # coroutine is awaiting.
+    await asyncio.sleep(HMS_ACTION_ACK_WAIT_SECONDS)
+
+    acked = client.state.state != pre_gcode or len(client.state.hms_errors) != pre_hms_count
+    if not acked:
+        # Publish succeeded but the printer's state didn't move. Almost always
+        # firmware-side silent rejection (err mismatch, command/state mismatch).
+        # 502 makes it visible at the UI instead of the 200-but-broken loop
+        # #1830 reported.
+        raise HTTPException(502, "Printer did not acknowledge HMS action within 2.5s")
+
+    return {"success": True, "message": "HMS action executed"}

+ 57 - 7
backend/app/api/routes/settings.py

@@ -5,10 +5,10 @@ import zipfile
 from datetime import datetime
 from pathlib import Path
 
-from fastapi import APIRouter, Depends, File, UploadFile
+from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
 from fastapi.responses import FileResponse, JSONResponse
 from pydantic import BaseModel, Field
-from sqlalchemy import delete, select
+from sqlalchemy import delete, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, caller_is_api_key, require_energy_cost_update
@@ -111,6 +111,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "spoolman_enabled",
             "spoolman_disable_weight_sync",
             "spoolman_report_partial_usage",
+            "auto_add_unknown_rfid",
             "disable_filament_warnings",
             "prefer_lowest_filament",
             "check_updates",
@@ -127,6 +128,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "queue_drying_enabled",
             "queue_drying_block",
             "ambient_drying_enabled",
+            "print_drying_enabled",
             "require_plate_clear",
             "queue_shortest_first",
             "default_bed_levelling",
@@ -137,6 +139,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "default_nozzle_offset_cali",
             "ldap_enabled",
             "ldap_auto_provision",
+            "local_login_enabled",
         ]:
             settings_dict[setting.key] = setting.value.lower() == "true"
         elif setting.key in [
@@ -152,6 +155,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "ams_humidity_good",
             "ams_humidity_fair",
             "ams_history_retention_days",
+            "printer_sensor_history_retention_days",
             "ftp_retry_count",
             "ftp_retry_delay",
             "ftp_timeout",
@@ -159,6 +163,7 @@ async def _build_settings_response(db: AsyncSession, is_api_key: bool = False) -
             "stagger_group_size",
             "stagger_interval_minutes",
             "forecast_global_lead_time_days",
+            "session_max_hours",
         ]:
             settings_dict[setting.key] = int(setting.value)
         elif setting.key == "default_printer_id":
@@ -200,11 +205,39 @@ async def get_settings(
 async def update_settings(
     settings_update: AppSettingsUpdate,
     db: AsyncSession = Depends(get_db),
-    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
 ):
     """Update application settings."""
     update_data = settings_update.model_dump(exclude_unset=True)
 
+    # Safety refusals on disabling local login (#1589). Two failure modes
+    # would otherwise lock everyone out of the install:
+    #   1. No enabled OIDC provider exists — nobody could authenticate.
+    #   2. The caller has no UserOIDCLink — they would lock themselves out
+    #      even if other admins are linked.
+    # Either case returns HTTP 400 instead of silently saving. The
+    # ``BAMBUDDY_LOCAL_LOGIN=true`` env-var bypass on /auth/login is a
+    # separate recovery path; the refusals here protect the *default*
+    # configuration where the env var is absent.
+    if update_data.get("local_login_enabled") is False:
+        from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
+
+        enabled_count = await db.scalar(select(func.count(OIDCProvider.id)).where(OIDCProvider.is_enabled.is_(True)))
+        if not enabled_count:
+            raise HTTPException(
+                status_code=400,
+                detail="Cannot disable local login: no OIDC provider is enabled.",
+            )
+        if current_user is not None:
+            caller_links = await db.scalar(
+                select(func.count(UserOIDCLink.id)).where(UserOIDCLink.user_id == current_user.id)
+            )
+            if not caller_links:
+                raise HTTPException(
+                    status_code=400,
+                    detail="Cannot disable local login: your account has no OIDC link, so you would lock yourself out.",
+                )
+
     # Check if any MQTT settings are being updated
     mqtt_keys = {
         "mqtt_enabled",
@@ -340,11 +373,18 @@ _UI_PREFERENCE_FIELDS: tuple[str, ...] = (
     "time_format",
     "date_format",
     "drying_presets",
+    "ams_humidity_thresholds",
     "ams_humidity_good",
     "ams_humidity_fair",
     "ams_temp_good",
     "ams_temp_fair",
     "bed_cooled_threshold",
+    # Temperature / fan-speed presets for the printer-card popovers. Numbers
+    # only; no PII / credentials.
+    "nozzle_temp_presets",
+    "bed_temp_presets",
+    "chamber_temp_presets",
+    "fan_speed_presets",
 )
 
 
@@ -398,6 +438,7 @@ async def get_spoolman_settings(
     spoolman_sync_mode = await get_setting(db, "spoolman_sync_mode") or "auto"
     spoolman_disable_weight_sync = await get_setting(db, "spoolman_disable_weight_sync") or "false"
     spoolman_report_partial_usage = await get_setting(db, "spoolman_report_partial_usage") or "true"
+    auto_add_unknown_rfid = await get_setting(db, "auto_add_unknown_rfid") or "true"
 
     return {
         "spoolman_enabled": spoolman_enabled,
@@ -405,6 +446,7 @@ async def get_spoolman_settings(
         "spoolman_sync_mode": spoolman_sync_mode,
         "spoolman_disable_weight_sync": spoolman_disable_weight_sync,
         "spoolman_report_partial_usage": spoolman_report_partial_usage,
+        "auto_add_unknown_rfid": auto_add_unknown_rfid,
     }
 
 
@@ -444,10 +486,20 @@ async def update_spoolman_settings(
         await set_setting(db, "spoolman_disable_weight_sync", settings["spoolman_disable_weight_sync"])
     if "spoolman_report_partial_usage" in settings:
         await set_setting(db, "spoolman_report_partial_usage", settings["spoolman_report_partial_usage"])
+    if "auto_add_unknown_rfid" in settings:
+        await set_setting(db, "auto_add_unknown_rfid", settings["auto_add_unknown_rfid"])
+
+    spoolman_changed = "spoolman_enabled" in settings or "spoolman_url" in settings
 
     await db.commit()
     db.expire_all()
 
+    if spoolman_changed:
+        from backend.app.services.location_service import maybe_sync_spoolman_locations
+
+        if await maybe_sync_spoolman_locations(db):
+            await db.commit()
+
     # Return updated settings
     return await get_spoolman_settings(db)
 
@@ -922,8 +974,8 @@ async def restore_backup(
             # 3b. Pause timer-based background services BEFORE the DB swap.
             # close_all_connections() below only disposes the engine's pool,
             # not the asyncio tasks that opened sessions from it. The print
-            # scheduler (30 s cadence), smart-plug snapshot loop (30 s),
-            # notification digest loop, and background dispatch worker all
+            # scheduler (30 s cadence), smart-plug snapshot loop (30 s), and
+            # notification digest loop all
             # wake up and call async_session(), which lazily re-creates a
             # pool connection holding RowExclusiveLock on print_queue /
             # smart_plug_energy_snapshots / etc. The DROP TABLE CASCADE
@@ -932,7 +984,6 @@ async def restore_backup(
             # full restore rollback. Successful restore already requires a
             # container restart, so we don't restart the services here.
             try:
-                from backend.app.services.background_dispatch import background_dispatch
                 from backend.app.services.notification_service import notification_service
                 from backend.app.services.print_scheduler import scheduler as print_scheduler
                 from backend.app.services.smart_plug_manager import smart_plug_manager
@@ -941,7 +992,6 @@ async def restore_backup(
                 print_scheduler.stop()
                 smart_plug_manager.stop_scheduler()
                 notification_service.stop_digest_scheduler()
-                await background_dispatch.stop()
                 # In-flight loop iterations need a moment to commit + release
                 # their DB sessions before we dispose() the engine pool.
                 await asyncio.sleep(1.0)

+ 55 - 0
backend/app/api/routes/sponsor_prompt.py

@@ -0,0 +1,55 @@
+"""API routes for the in-app sponsor toast."""
+
+import logging
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import RequirePermissionIfAuthEnabled
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.user import User
+from backend.app.schemas.sponsor_prompt import (
+    SponsorPromptCheckResponse,
+    SponsorPromptDismissRequest,
+)
+from backend.app.services import sponsor_prompt as service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/sponsor-prompt", tags=["sponsor-prompt"])
+
+
+def _user_id(current_user: User | None) -> int | None:
+    return current_user.id if current_user is not None else None
+
+
+@router.get("/check", response_model=SponsorPromptCheckResponse)
+async def check_sponsor_prompt(
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Return the next eligible sponsor-toast trigger, or `{show: false}`."""
+    trigger = await service.evaluate(db, _user_id(current_user))
+    await db.commit()
+    if trigger is None:
+        return SponsorPromptCheckResponse(show=False)
+    return SponsorPromptCheckResponse(
+        show=True,
+        milestone=trigger.milestone,
+        family=trigger.family,
+        threshold=trigger.threshold,
+        payload=trigger.payload,
+    )
+
+
+@router.post("/dismiss", status_code=status.HTTP_204_NO_CONTENT)
+async def dismiss_sponsor_prompt(
+    data: SponsorPromptDismissRequest,
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+    db: AsyncSession = Depends(get_db),
+):
+    """Anchor the 14-day cooldown and record the milestone as shown."""
+    await service.dismiss(db, _user_id(current_user), data.milestone)
+    await db.commit()
+    return None

+ 146 - 0
backend/app/api/routes/spoolman.py

@@ -222,6 +222,11 @@ async def sync_printer_ams(
     skipped: list[SkippedSpool] = []
     errors = []
 
+    from backend.app.api.routes.settings import get_setting
+
+    _auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
+    auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
+
     # Handle different AMS data structures
     # Traditional AMS: list of {"id": N, "tray": [...]} dicts
     # H2D/newer printers: dict with different structure
@@ -326,6 +331,7 @@ async def sync_printer_ams(
                     cached_spools=cached_spools,
                     inventory_remaining=inv_remaining,
                     spoolman_spool_id_hint=hint,
+                    auto_add_unknown_rfid=auto_add_unknown_rfid,
                 )
                 if sync_result:
                     synced += 1
@@ -338,6 +344,15 @@ async def sync_printer_ams(
                     logger.info(
                         "Synced %s from %s AMS %s tray %s", tray.tray_sub_brands, printer.name, ams_id, tray.tray_id
                     )
+                elif spool_tag and not auto_add_unknown_rfid:
+                    skipped.append(
+                        SkippedSpool(
+                            location=f"AMS {ams_id} T{tray.tray_id}",
+                            reason="Auto-add disabled; add to inventory manually",
+                            filament_type=tray.tray_type or None,
+                            color=tray.tray_color[:6] if tray.tray_color else None,
+                        )
+                    )
                 elif spool_tag:
                     errors.append(f"Spool not found in Spoolman: AMS {ams_id}:{tray.tray_id}")
                 elif not hint:
@@ -422,6 +437,11 @@ async def sync_all_printers(
     all_skipped: list[SkippedSpool] = []
     all_errors = []
 
+    from backend.app.api.routes.settings import get_setting
+
+    _auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
+    auto_add_unknown_rfid = _auto_add_raw is None or _auto_add_raw.lower() == "true"
+
     # OPTIMIZATION: Fetch all spools once before processing ALL printers/trays
     # This eliminates redundant API calls across all printers
     logger.debug("Fetching spools cache for sync-all operation...")
@@ -528,6 +548,7 @@ async def sync_all_printers(
                         cached_spools=cached_spools,
                         inventory_remaining=inv_remaining,
                         spoolman_spool_id_hint=hint,
+                        auto_add_unknown_rfid=auto_add_unknown_rfid,
                     )
                     if sync_result:
                         total_synced += 1
@@ -537,6 +558,15 @@ async def sync_all_printers(
                             if not spool_exists:
                                 cached_spools.append(sync_result)
                                 logger.debug("Added newly created spool %s to cache", sync_result["id"])
+                    elif spool_tag and not auto_add_unknown_rfid:
+                        all_skipped.append(
+                            SkippedSpool(
+                                location=f"{printer.name} AMS {ams_id} T{tray.tray_id}",
+                                reason="Auto-add disabled; add to inventory manually",
+                                filament_type=tray.tray_type or None,
+                                color=tray.tray_color[:6] if tray.tray_color else None,
+                            )
+                        )
                     elif spool_tag:
                         all_errors.append(f"Spool not found in Spoolman: {printer.name} AMS {ams_id}:{tray.tray_id}")
                     elif not hint:
@@ -1108,3 +1138,119 @@ async def unlink_spool(
 
     logger.info("Unlinked Spoolman spool %s", spool_id)
     return {"success": True, "message": f"Spool {spool_id} unlinked from AMS"}
+
+
+class CreateSpoolFromSlotRequest(BaseModel):
+    printer_id: int
+    ams_id: int
+    tray_id: int
+
+
+@router.post("/spools/from-slot")
+async def create_spool_from_slot(
+    req: CreateSpoolFromSlotRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.FILAMENTS_UPDATE),
+):
+    """Explicit user action: create a Spoolman spool from an AMS slot's current tray data.
+
+    Used by the "+ Add to inventory" affordance when auto_add_unknown_rfid is disabled —
+    the user looked at the slot and chose to register it. Calls sync_ams_tray with the
+    auto-add override on so the spool is created even when the global setting is off.
+    """
+    sm = await get_spoolman_settings(db)
+    if not sm["enabled"]:
+        raise HTTPException(status_code=400, detail="Spoolman integration is not enabled")
+
+    client = await get_spoolman_client()
+    if not client:
+        if sm["url"]:
+            client = await init_spoolman_client(sm["url"])
+        else:
+            raise HTTPException(status_code=400, detail="Spoolman URL is not configured")
+
+    if not await client.health_check():
+        raise HTTPException(status_code=503, detail="Spoolman is not reachable")
+
+    result = await db.execute(select(Printer).where(Printer.id == req.printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    state = printer_manager.get_status(req.printer_id)
+    if not state or not state.raw_data:
+        raise HTTPException(status_code=404, detail="Printer not connected or no state available")
+
+    ams_data = state.raw_data.get("ams")
+    ams_units: list[dict] = []
+    if isinstance(ams_data, list):
+        ams_units = ams_data
+    elif isinstance(ams_data, dict):
+        if "ams" in ams_data and isinstance(ams_data["ams"], list):
+            ams_units = ams_data["ams"]
+        elif "tray" in ams_data:
+            ams_units = [{"id": 0, "tray": ams_data.get("tray", [])}]
+
+    tray = None
+    for unit in ams_units:
+        if not isinstance(unit, dict):
+            continue
+        if int(unit.get("id", -1)) != req.ams_id:
+            continue
+        for t in unit.get("tray", []):
+            if isinstance(t, dict) and int(t.get("id", -1)) == req.tray_id:
+                tray = client.parse_ams_tray(req.ams_id, t)
+                break
+        if tray:
+            break
+
+    if not tray:
+        raise HTTPException(status_code=400, detail="Slot is empty or has no readable tray data")
+
+    # Same ghost-spool guard as the inventory route: no tag → no stable
+    # identity → confirm would just create a fresh Spoolman row per push.
+    from backend.app.services.spool_tag_matcher import is_valid_tag
+
+    if not is_valid_tag(tray.tag_uid or "", tray.tray_uuid or ""):
+        raise HTTPException(status_code=400, detail="Slot has no RFID tag")
+
+    sync_result = await client.sync_ams_tray(
+        tray,
+        printer.name,
+        disable_weight_sync=True,
+        auto_add_unknown_rfid=True,
+    )
+    if not sync_result:
+        raise HTTPException(status_code=500, detail="Spoolman did not create a spool from the slot")
+
+    # Persist the slot assignment so the new spool shows on the slot tile.
+    # If this fails, surface a 500 — silently returning success while the
+    # binding rolled back leaves the user thinking the spool was added,
+    # then watching the modal re-fire on the next MQTT push.
+    if sync_result.get("id"):
+        try:
+            await db.execute(
+                text(
+                    "INSERT INTO spoolman_slot_assignments"
+                    " (printer_id, ams_id, tray_id, spoolman_spool_id)"
+                    " VALUES (:printer_id, :ams_id, :tray_id, :spool_id)"
+                    " ON CONFLICT(printer_id, ams_id, tray_id)"
+                    " DO UPDATE SET spoolman_spool_id = excluded.spoolman_spool_id"
+                ),
+                {
+                    "printer_id": req.printer_id,
+                    "ams_id": req.ams_id,
+                    "tray_id": req.tray_id,
+                    "spool_id": sync_result["id"],
+                },
+            )
+            await db.commit()
+        except Exception as exc:
+            await db.rollback()
+            logger.exception("Failed to persist Spoolman slot assignment")
+            raise HTTPException(
+                status_code=500,
+                detail=f"Spool created in Spoolman but slot assignment failed: {exc}",
+            ) from exc
+
+    return {"success": True, "spool_id": sync_result.get("id")}

+ 190 - 7
backend/app/api/routes/spoolman_inventory.py

@@ -34,6 +34,7 @@ from backend.app.api.routes._spoolman_helpers import (
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
+from backend.app.core.websocket import ws_manager
 from backend.app.models.ams_label import AmsLabel
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
@@ -42,6 +43,11 @@ from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
 from backend.app.models.user import User
 from backend.app.schemas.spool import SpoolKProfileBase
 from backend.app.schemas.spoolman import SpoolmanFilamentPatch, SpoolmanSlotAssignmentEnriched
+from backend.app.services.location_service import (
+    enrich_spool_dicts_with_location_id,
+    maybe_sync_spoolman_locations,
+    resolve_spoolman_location_string,
+)
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.slicer_filament_resolver import resolve_slicer_filament
 from backend.app.services.spoolman import (
@@ -307,6 +313,7 @@ class SpoolmanInventoryCreate(BaseModel):
     note: str | None = Field(None, max_length=1000)
     cost_per_kg: float | None = Field(None, ge=0.0, le=1_000_000.0)
     storage_location: str | None = Field(None, max_length=255)
+    location_id: int | None = Field(None, gt=0)
     # BambuStudio slicer preset for this spool. Spoolman has no native field
     # for this, so we persist it under the bambu_slicer_filament[_name] keys
     # in the spool's extra dict and read it back in _map_spoolman_spool.
@@ -349,6 +356,7 @@ class SpoolmanInventoryUpdate(BaseModel):
     tag_uid: str | None = Field(None, min_length=8, max_length=30, pattern=r"^[0-9A-Fa-f]+$")
     tray_uuid: str | None = Field(None, min_length=32, max_length=32, pattern=r"^[0-9A-Fa-f]+$")
     storage_location: str | None = Field(None, max_length=255)
+    location_id: int | None = Field(None, gt=0)
     # BambuStudio slicer preset — persisted to Spoolman extra dict (see Create
     # schema). Pass an empty string to clear; null/omitted leaves unchanged.
     slicer_filament: str | None = Field(None, max_length=128)
@@ -430,6 +438,13 @@ async def list_spools(
 ) -> list[dict]:
     """Return all Spoolman spools in the InventorySpool format."""
     client = await _get_client(db)
+    # Sync after we have the route-resolved client so tests that patch the
+    # route module's get_spoolman_client/init_spoolman_client also catch the
+    # sync's client lookup — otherwise the location_service path imports from
+    # backend.app.services.spoolman directly and bypasses the patch.
+    if await maybe_sync_spoolman_locations(db, client=client):
+        await db.commit()
+
     async with _translate_spoolman_errors():
         spools = await client.get_all_spools(allow_archived=include_archived)
 
@@ -451,6 +466,7 @@ async def list_spools(
         for m in mapped:
             m["k_profiles"] = kp_by_spool.get(m["id"], [])
 
+    await enrich_spool_dicts_with_location_id(db, mapped)
     return mapped
 
 
@@ -472,6 +488,7 @@ async def get_spool(
 
     kp_result = await db.execute(select(SpoolmanKProfile).where(SpoolmanKProfile.spoolman_spool_id == spool_id))
     mapped["k_profiles"] = [_k_profile_to_dict(kp) for kp in kp_result.scalars().all()]
+    await enrich_spool_dicts_with_location_id(db, [mapped])
     return mapped
 
 
@@ -507,6 +524,18 @@ async def create_spool(
     client = await _get_client(db)
     filament_id = await _resolve_filament_id(data, client)
 
+    storage_location = data.storage_location
+    if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
+        try:
+            storage_location, _ = await resolve_spoolman_location_string(
+                db,
+                location_id=data.location_id,
+                storage_location=data.storage_location,
+                fields_set=set(data.model_fields_set),
+            )
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+
     remaining = max(0.0, data.label_weight - data.weight_used)
     try:
         async with _translate_spoolman_errors():
@@ -514,7 +543,7 @@ async def create_spool(
                 filament_id=filament_id,
                 remaining_weight=remaining,
                 comment=data.note or None,
-                location=data.storage_location or None,
+                location=storage_location or None,
             )
     except HTTPException as exc:
         if exc.status_code == 404 and data.spoolman_filament_id is not None:
@@ -556,6 +585,7 @@ async def create_spool(
                 )
 
     result = _map_spoolman_spool(spool)
+    await ws_manager.broadcast({"type": "inventory_changed"})
     if price_warnings:
         return JSONResponse(status_code=207, content={**result, "warnings": price_warnings})
     return result
@@ -581,6 +611,18 @@ async def bulk_create_spools(
             ) from exc
         raise
 
+    storage_location = data.storage_location
+    if "location_id" in data.model_fields_set or "storage_location" in data.model_fields_set:
+        try:
+            storage_location, _ = await resolve_spoolman_location_string(
+                db,
+                location_id=data.location_id,
+                storage_location=data.storage_location,
+                fields_set=set(data.model_fields_set),
+            )
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
+
     remaining = max(0.0, data.label_weight - data.weight_used)
     created: list[dict] = []
     failures: list[str] = []
@@ -590,7 +632,7 @@ async def bulk_create_spools(
                 filament_id=filament_id,
                 remaining_weight=remaining,
                 comment=data.note or None,
-                location=data.storage_location or None,
+                location=storage_location or None,
             )
         except (SpoolmanUnavailableError, SpoolmanClientError, SpoolmanNotFoundError) as exc:
             logger.warning("Bulk spool creation: one spool failed: %s", exc)
@@ -613,6 +655,8 @@ async def bulk_create_spools(
     if not created:
         raise HTTPException(status_code=500, detail="Failed to create any spools in Spoolman")
 
+    await ws_manager.broadcast({"type": "inventory_changed"})
+
     if len(created) < payload.quantity:
         # Some spool creations failed — return 207 Multi-Status so the caller
         # can distinguish a full success from a partial one and show a useful message.
@@ -679,8 +723,18 @@ async def update_spool(
         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
+    storage_location_changed = "storage_location" in data.model_fields_set or "location_id" in data.model_fields_set
+    storage_location = data.storage_location if "storage_location" in data.model_fields_set else None
+    if storage_location_changed:
+        try:
+            storage_location, _ = await resolve_spoolman_location_string(
+                db,
+                location_id=data.location_id,
+                storage_location=storage_location,
+                fields_set=set(data.model_fields_set),
+            )
+        except ValueError as exc:
+            raise HTTPException(status_code=400, detail=str(exc)) from exc
 
     color_hex = rgba[:6]
 
@@ -817,6 +871,7 @@ async def update_spool(
         async with _translate_spoolman_errors():
             updated = await client.merge_spool_extra(spool_id, new_extra)
 
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return _map_spoolman_spool(updated)
 
 
@@ -830,6 +885,7 @@ async def delete_spool(
     client = await _get_client(db)
     async with _translate_spoolman_errors():
         await client.delete_spool(spool_id)
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return {"status": "deleted"}
 
 
@@ -844,10 +900,12 @@ async def archive_spool(
     async with _translate_spoolman_errors():
         spool = await client.set_spool_archived(spool_id, archived=True)
     try:
-        return _map_spoolman_spool(spool)
+        mapped = _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
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return mapped
 
 
 @router.post("/spools/{spool_id}/restore")
@@ -861,10 +919,12 @@ async def restore_spool(
     async with _translate_spoolman_errors():
         spool = await client.set_spool_archived(spool_id, archived=False)
     try:
-        return _map_spoolman_spool(spool)
+        mapped = _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
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return mapped
 
 
 @router.post("/spools/{spool_id}/reset-consumed-counter")
@@ -888,10 +948,129 @@ async def reset_spool_consumed_counter(
     async with _translate_spoolman_errors():
         spool = await client.reset_spool_usage(spool_id)
     try:
-        return _map_spoolman_spool(spool)
+        mapped = _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
+    await ws_manager.broadcast({"type": "inventory_changed"})
+    return mapped
+
+
+class SpoolmanBulkUpdateRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+    update: SpoolmanInventoryUpdate
+
+
+class SpoolmanBulkIdsRequest(BaseModel):
+    ids: list[int] = Field(..., min_length=1, max_length=500)
+
+
+@router.post("/spools/bulk-update")
+async def bulk_update_spools(
+    payload: SpoolmanBulkUpdateRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Apply the same partial update to every listed Spoolman spool.
+
+    Loops the per-spool ``update_spool`` route so the filament re-linking +
+    extra-dict + location-resolution rules stay in sync with the single-spool
+    PATCH path. Per-spool errors are collected; one bad ID doesn't abort the
+    batch.
+    """
+    update_fields = payload.update.model_dump(exclude_unset=True)
+    if not update_fields:
+        raise HTTPException(status_code=400, detail="update must include at least one field")
+
+    updated = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            await update_spool(spool_id=sid, data=payload.update, db=db, _=None)
+            updated += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-update failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if updated:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"updated": updated, "errors": errors}
+
+
+@router.post("/spools/bulk-delete")
+async def bulk_delete_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Hard-delete every listed Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    deleted = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.delete_spool(sid)
+            deleted += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-delete failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if deleted:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"deleted": deleted, "errors": errors}
+
+
+@router.post("/spools/bulk-archive")
+async def bulk_archive_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Archive every listed Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    archived = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.set_spool_archived(sid, archived=True)
+            archived += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-archive failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if archived:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"archived": archived, "errors": errors}
+
+
+@router.post("/spools/bulk-restore")
+async def bulk_restore_spools(
+    payload: SpoolmanBulkIdsRequest,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
+) -> dict:
+    """Restore every listed archived Spoolman spool. Per-spool failures are collected."""
+    client = await _get_client(db)
+    restored = 0
+    errors: list[dict] = []
+    for sid in payload.ids:
+        try:
+            async with _translate_spoolman_errors():
+                await client.set_spool_archived(sid, archived=False)
+            restored += 1
+        except HTTPException as exc:
+            errors.append({"id": sid, "status": exc.status_code, "detail": exc.detail})
+        except Exception as exc:  # noqa: BLE001 — surface unexpected failures per-row
+            logger.exception("Spoolman bulk-restore failed for spool %s", sid)
+            errors.append({"id": sid, "status": 500, "detail": str(exc)})
+    if restored:
+        await ws_manager.broadcast({"type": "inventory_changed"})
+    return {"restored": restored, "errors": errors}
 
 
 @router.post("/spools/reset-consumed-counter-bulk")
@@ -922,6 +1101,8 @@ async def bulk_reset_spool_consumed_counter(
             reset_count += 1
         except HTTPException as exc:
             logger.warning("Spoolman reset-consumed-counter failed for spool %s: %s", spool_id, exc.detail)
+    if reset_count:
+        await ws_manager.broadcast({"type": "inventory_changed"})
     return {"reset": reset_count}
 
 
@@ -955,6 +1136,7 @@ async def sync_spool_weight(
     upd_filament = updated.get("filament") or {}
     label_weight = _safe_int(upd_filament.get("weight"), 1000)
     weight_used = max(0.0, label_weight - remaining)
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return {"status": "ok", "weight_used": weight_used}
 
 
@@ -997,6 +1179,7 @@ async def link_tag_to_spoolman_spool(
             updated = await client.update_spool_full(spool_id=spool_id, extra=cur_extra)
 
     logger.info("Linked tag %s to Spoolman spool %s", tag, spool_id)
+    await ws_manager.broadcast({"type": "inventory_changed"})
     return _map_spoolman_spool(updated)
 
 

+ 28 - 0
backend/app/api/routes/system.py

@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.config import APP_VERSION, settings
 from backend.app.core.database import get_db
+from backend.app.core.local_config import read_local_toml, read_ntp_gate
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
@@ -603,3 +604,30 @@ async def get_system_health(
     """
     sensitive_strings = await collect_sensitive_strings(db)
     return await asyncio.to_thread(scan_logs, sensitive_strings=sensitive_strings)
+
+
+@router.get("/appliance")
+async def get_appliance_defaults():
+    """Expose appliance-set state for the SPA's bootstrap surface.
+
+    Two file sources, both optional and silently degraded when absent:
+
+    - ``/etc/bambuddy/local.toml`` — hostname / timezone / locale the
+      firstboot wizard collected.
+    - ``/run/bambuddy/time-synced`` — chrony NTP gate state. The RPi 5 has
+      no battery-backed RTC, so on a fresh boot the clock is wrong until
+      ntp-gate.sh writes "ok" (or "warning" if 3-minute timeout elapsed).
+      A warning state means JWT expiries and TLS validity windows may be
+      misaligned; the UI should surface this.
+
+    No auth required — the frontend bootstrap reads this BEFORE auth might
+    be set up, and the contents are user-set defaults plus a public sync
+    flag (no secrets).
+    """
+    config = read_local_toml()
+    return {
+        "hostname": config.get("hostname"),
+        "timezone": config.get("timezone"),
+        "locale": config.get("locale"),
+        "time_synced": read_ntp_gate(),
+    }

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

@@ -120,6 +120,50 @@ def _is_ha_addon() -> bool:
     return bool(os.environ.get("SUPERVISOR_TOKEN"))
 
 
+def _is_windows_installer_install() -> bool:
+    """Detect a Windows install that came from the Inno Setup installer.
+
+    The installer stages backend source via ``shutil.copytree`` (no ``.git``
+    directory) and does not bundle ``git.exe`` — so the git-fetch-and-reset
+    update path used everywhere else is structurally inoperable here. We
+    surface this as a distinct ``update_method`` and direct the user at the
+    release asset instead.
+
+    A Windows developer running from a real ``git clone`` keeps the git
+    path (``.git`` present), so this only catches installer users.
+    """
+    if sys.platform != "win32":
+        return False
+    return not (settings.app_dir / ".git").exists()
+
+
+def _find_windows_installer_asset(release_data: dict) -> str | None:
+    """Pick the Windows installer .exe out of a GitHub release's assets list.
+
+    Both filenames the workflow uploads end in ``windows-x64-setup.exe``
+    (versioned ``bambuddy-<version>-windows-x64-setup.exe`` and the
+    unversioned alias ``bambuddy-windows-x64-setup.exe`` on non-daily tags
+    only). Either works as a download URL; we prefer the versioned form
+    because it's the one guaranteed to exist on every release including
+    dailies.
+    """
+    assets = release_data.get("assets") or []
+    versioned: str | None = None
+    unversioned: str | None = None
+    for asset in assets:
+        name = asset.get("name") or ""
+        url = asset.get("browser_download_url")
+        if not isinstance(name, str) or not isinstance(url, str):
+            continue
+        if not name.endswith("windows-x64-setup.exe"):
+            continue
+        if name == "bambuddy-windows-x64-setup.exe":
+            unversioned = url
+        else:
+            versioned = url
+    return versioned or unversioned
+
+
 def _find_executable(name: str) -> str | None:
     """Find an executable in PATH or common locations."""
     # Try standard PATH first
@@ -459,10 +503,15 @@ async def check_for_updates(
 
             is_docker = _is_docker_environment()
             is_ha_addon = _is_ha_addon()
+            is_windows_installer = _is_windows_installer_install()
+            installer_download_url: str | None = None
             if is_ha_addon:
                 update_method = "ha_addon"
             elif is_docker:
                 update_method = "docker"
+            elif is_windows_installer:
+                update_method = "windows_installer"
+                installer_download_url = _find_windows_installer_asset(release_data)
             else:
                 update_method = "git"
             return {
@@ -475,7 +524,9 @@ async def check_for_updates(
                 "published_at": published_at,
                 "is_docker": is_docker,
                 "is_ha_addon": is_ha_addon,
+                "is_windows_installer": is_windows_installer,
                 "update_method": update_method,
+                "installer_download_url": installer_download_url,
             }
 
     except httpx.HTTPError as e:
@@ -827,6 +878,19 @@ async def apply_update(
                 "git pull && docker compose build --pull && docker compose up -d"
             ),
         }
+    if _is_windows_installer_install():
+        # The installer layout has no ``.git`` and no bundled ``git.exe`` —
+        # the git-fetch path would fail. Frontend swaps the "Update now"
+        # button for a Download Installer link via update_method, so this
+        # branch is only reached if /apply is hit directly.
+        return {
+            "success": False,
+            "is_windows_installer": True,
+            "message": (
+                "Windows installations are updated by re-running the installer. "
+                "Download the latest installer from the Bambuddy releases page."
+            ),
+        }
 
     # Discover which release tag to install. Resolved here (where we have
     # a DB session) and passed into the background task; the BG task can't

+ 6 - 0
backend/app/api/routes/virtual_printers.py

@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
+    gcode_injection: bool = False
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
 
@@ -52,6 +53,7 @@ class VirtualPrinterUpdate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
+    gcode_injection: bool | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     tailscale_disabled: bool | None = None
@@ -107,6 +109,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
+        "gcode_injection": vp.gcode_injection,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "tailscale_disabled": vp.tailscale_disabled,
@@ -242,6 +245,7 @@ async def create_virtual_printer(
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
+        gcode_injection=body.gcode_injection,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
         serial_suffix=new_suffix,
@@ -419,6 +423,8 @@ async def update_virtual_printer(
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:
         vp.queue_force_color_match = body.queue_force_color_match
+    if body.gcode_injection is not None:
+        vp.gcode_injection = body.gcode_injection
     if body.bind_ip is not None:
         vp.bind_ip = body.bind_ip
     if body.remote_interface_ip is not None:

+ 12 - 11
backend/app/api/routes/websocket.py

@@ -23,7 +23,6 @@ from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
 from backend.app.core.auth import is_auth_enabled, verify_websocket_token
 from backend.app.core.database import async_session
 from backend.app.core.websocket import ws_manager
-from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.printer_manager import printer_manager, printer_state_to_dict
 
 logger = logging.getLogger(__name__)
@@ -97,18 +96,15 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                 {
                     "type": "printer_status",
                     "printer_id": printer_id,
-                    "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                    "data": printer_state_to_dict(
+                        state,
+                        printer_id,
+                        printer_manager.get_model(printer_id),
+                        printer_manager.get_drying_targets(printer_id),
+                    ),
                 }
             )
 
-        dispatch_state = await background_dispatch.get_state()
-        if (dispatch_state.get("dispatched", 0) + dispatch_state.get("processing", 0)) > 0:
-            await websocket.send_json(
-                {
-                    "type": "background_dispatch",
-                    "data": dispatch_state,
-                }
-            )
         logger.info("Sent initial status for %s printers", len(statuses))
 
         # Keep connection alive and handle incoming messages.
@@ -129,7 +125,12 @@ async def websocket_endpoint(websocket: WebSocket, token: str | None = Query(def
                             {
                                 "type": "printer_status",
                                 "printer_id": printer_id,
-                                "data": printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                                "data": printer_state_to_dict(
+                                    state,
+                                    printer_id,
+                                    printer_manager.get_model(printer_id),
+                                    printer_manager.get_drying_targets(printer_id),
+                                ),
                             }
                         )
 

+ 52 - 6
backend/app/core/auth.py

@@ -88,6 +88,7 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.EXTERNAL_LINKS_READ: "can_read_status",
     Permission.FIRMWARE_READ: "can_read_status",
     Permission.AMS_HISTORY_READ: "can_read_status",
+    Permission.PRINTER_SENSOR_HISTORY_READ: "can_read_status",
     Permission.STATS_READ: "can_read_status",
     Permission.STATS_FILTER_BY_USER: "can_read_status",
     Permission.SYSTEM_READ: "can_read_status",
@@ -111,13 +112,21 @@ _APIKEY_SCOPE_BY_PERMISSION: dict[Permission, str] = {
     Permission.PRINTERS_AMS_RFID: "can_control_printer",
     Permission.PRINTERS_CLEAR_PLATE: "can_control_printer",
     Permission.SMART_PLUGS_CONTROL: "can_control_printer",
-    # can_manage_library — file-manager scope (upload/rename/delete OWN library
+    # can_manage_library — file-manager scope (upload/rename/delete library
     # entries + MakerWorld import which downloads files into the library).
-    # Bulk/ALL-ownership library ops (UPDATE_ALL / DELETE_ALL / PURGE) stay
-    # admin-only because they cross the user boundary.
+    # OWN and ALL ownership variants map to the same scope so the
+    # `require_ownership_permission` checker (which gates on `all_perm`)
+    # passes the API key through. This matches `can_queue` and the
+    # archives/inventory scopes — API keys have no per-row ownership identity
+    # (line 1663), so splitting OWN/ALL across allowlist/denylist made the
+    # whole library curation surface unreachable for API keys (#1832).
+    # LIBRARY_PURGE stays admin-only as a genuinely destructive op that
+    # bypasses the soft-delete window.
     Permission.LIBRARY_UPLOAD: "can_manage_library",
     Permission.LIBRARY_UPDATE_OWN: "can_manage_library",
+    Permission.LIBRARY_UPDATE_ALL: "can_manage_library",
     Permission.LIBRARY_DELETE_OWN: "can_manage_library",
+    Permission.LIBRARY_DELETE_ALL: "can_manage_library",
     Permission.MAKERWORLD_IMPORT: "can_manage_library",
     # can_manage_inventory — inventory write scope. Covers the documented
     # spool/catalog/forecast write surface AND the SpoolBuddy kiosk endpoints
@@ -182,8 +191,11 @@ _APIKEY_DENIED_PERMISSIONS: frozenset[Permission] = frozenset(
         Permission.ARCHIVES_DELETE_OWN,
         Permission.ARCHIVES_DELETE_ALL,
         Permission.ARCHIVES_PURGE,
-        Permission.LIBRARY_UPDATE_ALL,
-        Permission.LIBRARY_DELETE_ALL,
+        # LIBRARY_UPDATE_ALL / LIBRARY_DELETE_ALL moved to the allowlist
+        # under `can_manage_library` (#1832) — split between allow/deny made
+        # the whole library curation surface unreachable for API keys via
+        # `require_ownership_permission`. Purge stays denied as a genuinely
+        # destructive op.
         Permission.LIBRARY_PURGE,
         Permission.PROJECTS_CREATE,
         Permission.PROJECTS_UPDATE,
@@ -421,10 +433,42 @@ def _get_jwt_secret() -> str:
 SECRET_KEY = _get_jwt_secret()
 ALGORITHM = "HS256"
 ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24  # 24 hours (M-2: reduced from 7 days)
+# Hard ceiling for the admin-configurable session policy (#1706). 30 days
+# matches the Pydantic le=720 on AppSettings.session_max_hours; defense in
+# depth so a tampered settings row can't request an absurd lifetime.
+SESSION_MAX_HOURS_HARD_CEILING = 720
 
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 
+
+async def resolve_session_max_minutes(db: AsyncSession) -> int:
+    """Return the session-lifetime ceiling (minutes) honoured by login routes.
+
+    Reads ``session_max_hours`` from the settings table (#1706), clamps to
+    [1h, 720h], and falls back to the audit-default 24h if the row is
+    missing, blank, or unparseable.
+
+    DB errors are NOT caught here — login is already in a DB transaction and
+    a broken DB must abort the login rather than silently extend or shrink
+    the session lifetime.
+    """
+    default_minutes = ACCESS_TOKEN_EXPIRE_MINUTES
+    result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
+    row = result.scalar_one_or_none()
+    if row is None or not row.value:
+        return default_minutes
+    try:
+        hours = int(row.value)
+    except (TypeError, ValueError):
+        return default_minutes
+    if hours < 1:
+        return default_minutes
+    if hours > SESSION_MAX_HOURS_HARD_CEILING:
+        hours = SESSION_MAX_HOURS_HARD_CEILING
+    return hours * 60
+
+
 # --- Slicer download tokens ---
 # Short-lived, single-use tokens for slicer protocol handlers that can't send
 # auth headers.  Stored in AuthEphemeralToken (token_type=TokenType.SLICER_DOWNLOAD)
@@ -649,7 +693,9 @@ def _is_token_fresh(iat: int | float | None, user: User) -> bool:
     Used to invalidate all sessions after a password reset/change (M-R7-B).
     All tokens without an iat claim are unconditionally rejected — every token
     issued by this server carries iat, so absence means the token is forged or
-    from a pre-iat code path whose max TTL (24 h) has long since expired.
+    from a pre-iat code path whose max TTL at the time (24 h) has long since
+    expired. The post-#1706 admin-set ceiling does not relax this — an iat-less
+    token still cannot have been issued by current code.
     """
     if iat is None:
         return False

+ 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.7"
+APP_VERSION = "0.2.4.8"
 GITHUB_REPO = "maziggy/bambuddy"
 BUG_REPORT_RELAY_URL = os.environ.get("BUG_REPORT_RELAY_URL", "https://bambuddy.cool/api/bug-report")
 

+ 367 - 8
backend/app/core/database.py

@@ -181,6 +181,7 @@ async def init_db():
         kprofile_note,
         library,
         local_preset,
+        location,
         long_lived_token,
         maintenance,
         notification,
@@ -192,6 +193,7 @@ async def init_db():
         print_log,
         print_queue,
         printer,
+        printer_sensor_history,
         project,
         project_bom,
         settings,
@@ -946,6 +948,16 @@ async def run_migrations(conn):
     else:
         await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
 
+    # Migration: cleanup flag for transient printer-card uploads routed through
+    # the scheduler. The archive copy is durable; the library row/file can be
+    # deleted after dispatch.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(
+            conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT false"
+        )
+
     # Migration: Add queue_force_color_match column to virtual_printers (#1188).
     # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
     # from the 3MF onto the queue item's filament_overrides so the scheduler
@@ -958,6 +970,26 @@ async def run_migrations(conn):
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
         )
 
+    # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
+    # existing gcode_snippets users don't silently start injecting on VP/Studio
+    # Send jobs after upgrading.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
+
+    # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
+    # slicer-pick preservation (#1780). Opaque JSON-string column carrying
+    # BambuStudio's per-filament physical nozzle position IDs, forwarded
+    # straight from the VP intake to the dispatcher's project_file MQTT
+    # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite
+    # divergence here. `nozzles_info` shipped in the original #1780 attempt
+    # but BambuStudio never actually sends it (verified via wire capture on
+    # H2C, see CHANGELOG 0.2.5b1) — the column stays nullable so old rows
+    # still load; nothing reads or writes to it anymore.
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
+
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 
@@ -1506,7 +1538,10 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         pass  # Already applied
 
-    # Create active_print_spoolman table for Spoolman per-filament tracking
+    # Create active_print_spoolman table for Spoolman per-filament tracking.
+    # filament_usage is nullable so the no-3MF branch can still create a row
+    # that carries only tray_remain_start for the remain%-delta fallback
+    # (#1820 — matches internal-inventory Path 2 in usage_tracker).
     await _safe_execute(
         conn,
         """
@@ -1514,11 +1549,12 @@ async def run_migrations(conn):
             id INTEGER PRIMARY KEY AUTOINCREMENT,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
             archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
+            filament_usage TEXT,
             ams_trays TEXT NOT NULL,
             slot_to_tray TEXT,
             layer_usage TEXT,
             filament_properties TEXT,
+            tray_remain_start TEXT,
             UNIQUE(printer_id, archive_id)
         )
         """
@@ -1528,15 +1564,50 @@ async def run_migrations(conn):
             id SERIAL PRIMARY KEY,
             printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
             archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
-            filament_usage TEXT NOT NULL,
+            filament_usage TEXT,
             ams_trays TEXT NOT NULL,
             slot_to_tray TEXT,
             layer_usage TEXT,
             filament_properties TEXT,
+            tray_remain_start TEXT,
             UNIQUE(printer_id, archive_id)
         )
         """,
     )
+    # Migration for installs that already created active_print_spoolman with
+    # the original schema: add tray_remain_start, and relax filament_usage's
+    # NOT NULL so the no-3MF branch can persist a remain-only tracking row.
+    await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_remain_start TEXT")
+    if is_sqlite():
+        # SQLite can't ALTER COLUMN; patch sqlite_master directly. Mirrors the
+        # users.password_hash NULL-relaxation a few hundred lines below — see
+        # the comment there for the schema_version bump rationale.
+        try:
+            result = await conn.execute(
+                text("SELECT sql FROM sqlite_master WHERE type='table' AND name='active_print_spoolman'")
+            )
+            tbl_sql = result.scalar()
+            if tbl_sql and "filament_usage TEXT NOT NULL" in tbl_sql:
+                version_result = await conn.execute(text("PRAGMA schema_version"))
+                schema_version = version_result.scalar() or 0
+                await conn.execute(text("PRAGMA writable_schema = ON"))
+                await conn.execute(
+                    text(
+                        "UPDATE sqlite_master "
+                        "SET sql = replace(sql, 'filament_usage TEXT NOT NULL', 'filament_usage TEXT') "
+                        "WHERE type='table' AND name='active_print_spoolman'"
+                    )
+                )
+                await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
+                await conn.execute(text("PRAGMA writable_schema = OFF"))
+        except (OperationalError, ProgrammingError) as exc:
+            logger.warning(
+                "Could not relax active_print_spoolman.filament_usage NOT NULL via writable_schema: %s — "
+                "no-3MF Spoolman fallback will be a no-op on this install",
+                exc,
+            )
+    else:
+        await _safe_execute(conn, "ALTER TABLE active_print_spoolman ALTER COLUMN filament_usage DROP NOT NULL")
 
     # Migration: Add preset_source column to slot_preset_mappings for local preset support
     try:
@@ -2577,9 +2648,10 @@ async def run_migrations(conn):
                 lead_time_days INTEGER NOT NULL DEFAULT 0,
                 safety_margin_value INTEGER NOT NULL DEFAULT 14,
                 safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
+                color_name VARCHAR(100),
                 created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                 updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-                UNIQUE (material, subtype, brand)
+                UNIQUE (material, subtype, brand, color_name)
             )""",
         )
         async with conn.begin_nested():
@@ -2593,6 +2665,12 @@ async def run_migrations(conn):
         await _safe_execute(
             conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0"
         )
+        # Migration: add color_name to filament_sku_settings so forecasts
+        # distinguish colours within a SKU. The matching ALTER for
+        # filament_shopping_list runs AFTER that table's CREATE below — on
+        # fresh installs the table doesn't exist yet at this point and
+        # _safe_execute does not swallow "no such table".
+        await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN color_name VARCHAR(100)")
         # Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild.
         # Only run if the stale column still exists.
         cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)"))
@@ -2615,28 +2693,77 @@ async def run_migrations(conn):
                         material VARCHAR(50) NOT NULL,
                         subtype VARCHAR(50),
                         brand VARCHAR(100),
+                        color_name VARCHAR(100),
                         lead_time_days INTEGER NOT NULL DEFAULT 0,
                         safety_margin_value INTEGER NOT NULL DEFAULT 14,
                         safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
                         alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
                         created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
                         updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
-                        UNIQUE (material, subtype, brand)
+                        UNIQUE (material, subtype, brand, color_name)
                     )"""
                     )
                 )
                 await conn.execute(
                     text(
                         """INSERT INTO filament_sku_settings_new
-                        (id, material, subtype, brand, lead_time_days, safety_margin_value,
+                        (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
                          safety_margin_unit, alerts_snoozed, created_at, updated_at)
-                       SELECT id, material, subtype, brand, lead_time_days, safety_margin_value,
+                       SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
                               safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
                        FROM filament_sku_settings"""
                     )
                 )
                 await conn.execute(text("DROP TABLE filament_sku_settings"))
                 await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings"))
+        # Widen the unique key to include color_name on pre-existing tables. The
+        # auto-created UNIQUE index still covers only (material, subtype, brand)
+        # after the ADD COLUMN above, so rebuild the table to refresh it (#forecast
+        # -color-grouping). Detected by inspecting the index columns; skipped once
+        # color_name is already part of the key.
+        idx_rows = await conn.execute(text("PRAGMA index_list(filament_sku_settings)"))
+        needs_uq_rebuild = False
+        for idx in idx_rows.fetchall():
+            if idx[3] != "u":  # origin col: 'u' = UNIQUE constraint, 'c' = CREATE INDEX, 'pk' = primary key
+                continue
+            info = await conn.execute(text(f"PRAGMA index_info({idx[1]})"))
+            cols = {row[2] for row in info.fetchall()}
+            if "material" in cols and "color_name" not in cols:
+                needs_uq_rebuild = True
+                break
+        if needs_uq_rebuild:
+            async with conn.begin_nested():
+                await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_uqfix"))
+                await conn.execute(
+                    text(
+                        """CREATE TABLE filament_sku_settings_uqfix (
+                        id INTEGER PRIMARY KEY AUTOINCREMENT,
+                        material VARCHAR(50) NOT NULL,
+                        subtype VARCHAR(50),
+                        brand VARCHAR(100),
+                        color_name VARCHAR(100),
+                        lead_time_days INTEGER NOT NULL DEFAULT 0,
+                        safety_margin_value INTEGER NOT NULL DEFAULT 14,
+                        safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
+                        alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
+                        created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                        updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+                        UNIQUE (material, subtype, brand, color_name)
+                    )"""
+                    )
+                )
+                await conn.execute(
+                    text(
+                        """INSERT INTO filament_sku_settings_uqfix
+                        (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
+                         safety_margin_unit, alerts_snoozed, created_at, updated_at)
+                       SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
+                              safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
+                       FROM filament_sku_settings"""
+                    )
+                )
+                await conn.execute(text("DROP TABLE filament_sku_settings"))
+                await conn.execute(text("ALTER TABLE filament_sku_settings_uqfix RENAME TO filament_sku_settings"))
         await _safe_execute(
             conn,
             """CREATE TABLE IF NOT EXISTS filament_shopping_list (
@@ -2644,6 +2771,7 @@ async def run_migrations(conn):
                 material VARCHAR(50) NOT NULL,
                 subtype VARCHAR(50),
                 brand VARCHAR(100),
+                color_name VARCHAR(100),
                 quantity_spools INTEGER NOT NULL DEFAULT 1,
                 note VARCHAR(500),
                 status VARCHAR(20) NOT NULL DEFAULT 'pending',
@@ -2651,6 +2779,10 @@ async def run_migrations(conn):
                 added_at DATETIME DEFAULT CURRENT_TIMESTAMP
             )""",
         )
+        # Backfill color_name on pre-#1814 upgrades — the CREATE above already
+        # has it for fresh installs; the ALTER is the upgrade path. "duplicate
+        # column name" is swallowed by _safe_execute, so re-runs are no-ops.
+        await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN color_name VARCHAR(100)")
         # SQLite has no implicit updated_at trigger — add one so the column stays current.
         await _safe_execute(
             conn,
@@ -2671,9 +2803,10 @@ async def run_migrations(conn):
                 lead_time_days INTEGER NOT NULL DEFAULT 0,
                 safety_margin_value INTEGER NOT NULL DEFAULT 14,
                 safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
+                color_name VARCHAR(100),
                 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-                UNIQUE (material, subtype, brand)
+                UNIQUE (material, subtype, brand, color_name)
             )""",
         )
         async with conn.begin_nested():
@@ -2690,6 +2823,35 @@ async def run_migrations(conn):
             conn,
             "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE",
         )
+        # Migration: add color_name to filament_sku_settings and widen the
+        # unique key to include it so forecasts distinguish colours within a
+        # SKU (#forecast-color-grouping). The matching ALTER for
+        # filament_shopping_list runs AFTER that table's CREATE below — on
+        # fresh installs the table doesn't exist yet at this point.
+        await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
+        # Widen UNIQUE (material, subtype, brand) → (material, subtype, brand, color_name).
+        # The original constraint was declared with name="uq_filament_sku" in the
+        # model, so we drop/re-add by that name. Gated on a pg_constraint lookup so
+        # the rebuild only runs when color_name is missing from the key — without
+        # the gate, every startup would take an ACCESS EXCLUSIVE lock on the table
+        # and churn the constraint.
+        uq_check = await conn.execute(
+            text(
+                "SELECT 1 FROM pg_constraint c "
+                "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) "
+                "WHERE c.conname = 'uq_filament_sku' AND a.attname = 'color_name' LIMIT 1"
+            )
+        )
+        if uq_check.scalar_one_or_none() is None:
+            await _safe_execute(
+                conn,
+                "ALTER TABLE filament_sku_settings DROP CONSTRAINT IF EXISTS uq_filament_sku",
+            )
+            await _safe_execute(
+                conn,
+                "ALTER TABLE filament_sku_settings ADD CONSTRAINT uq_filament_sku "
+                "UNIQUE (material, subtype, brand, color_name)",
+            )
         # Only backfill from safety_margin_days if that column still exists (PostgreSQL).
         col_check = await conn.execute(
             text(
@@ -2712,6 +2874,7 @@ async def run_migrations(conn):
                 material VARCHAR(50) NOT NULL,
                 subtype VARCHAR(50),
                 brand VARCHAR(100),
+                color_name VARCHAR(100),
                 quantity_spools INTEGER NOT NULL DEFAULT 1,
                 note VARCHAR(500),
                 status VARCHAR(20) NOT NULL DEFAULT 'pending',
@@ -2724,6 +2887,9 @@ async def run_migrations(conn):
             "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'pending'",
         )
         await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMP")
+        # Backfill color_name on pre-#1814 upgrades — the CREATE above already
+        # has it for fresh installs; the ALTER is the upgrade path.
+        await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
 
     # Migration: Add inventory stock alert columns to notification_providers.
     # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
@@ -2914,6 +3080,199 @@ async def run_migrations(conn):
     # file metadata so the FileManager displays the filename, not the title (#1489).
     await _migrate_drop_library_print_name(conn)
 
+    # Backfill NULL print_archives.created_at — older rows (and rows imported
+    # via the SQLite ↔ Postgres cross-DB restore path) can land with NULL
+    # because the column was originally created without a DEFAULT clause and
+    # server_default=func.now() only fires at table creation, not column
+    # population. The list_archives response model requires a datetime, so a
+    # single NULL row 500s the whole endpoint (#1732).
+    async with conn.begin_nested():
+        if is_sqlite():
+            await conn.execute(
+                text(
+                    "UPDATE print_archives "
+                    "SET created_at = COALESCE(completed_at, started_at, datetime('now')) "
+                    "WHERE created_at IS NULL"
+                )
+            )
+        else:
+            await conn.execute(
+                text(
+                    "UPDATE print_archives "
+                    "SET created_at = COALESCE(completed_at, started_at, NOW()) "
+                    "WHERE created_at IS NULL"
+                )
+            )
+
+    # Migration: structured storage locations (#1004). Flat catalog of physical
+    # shelves/drawers; spool.location_id FK with storage_location kept denormalized.
+    await _safe_execute(
+        conn,
+        """
+        CREATE TABLE IF NOT EXISTS locations (
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            name VARCHAR(255) NOT NULL UNIQUE,
+            name_key VARCHAR(255),
+            identifier VARCHAR(100),
+            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+        )
+        """
+        if is_sqlite()
+        else """
+        CREATE TABLE IF NOT EXISTS locations (
+            id SERIAL PRIMARY KEY,
+            name VARCHAR(255) NOT NULL UNIQUE,
+            name_key VARCHAR(255),
+            identifier VARCHAR(100),
+            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+        )
+        """,
+    )
+    await _safe_execute(conn, "ALTER TABLE locations ADD COLUMN name_key VARCHAR(255)")
+    await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_locations_name_key ON locations (name_key)")
+    await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN location_id INTEGER REFERENCES locations(id)")
+    await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_location_id ON spool (location_id)")
+
+    # Backfill name_key on legacy rows FIRST. If a pre-existing locations
+    # row was manually inserted before this migration ran, its name_key is
+    # NULL. The dedup INSERT below would then be silently skipped by
+    # UNIQUE(name) (legacy row already has the name), AND the spool-link
+    # UPDATE that joins on name_key would miss it. Doing this backfill BEFORE
+    # the INSERT keeps the join consistent on both branches of the migration.
+    async with conn.begin_nested():
+        await conn.execute(
+            text(
+                """
+                UPDATE locations
+                SET name_key = LOWER(TRIM(name))
+                WHERE name_key IS NULL OR TRIM(name_key) = ''
+                """
+            )
+        )
+
+    # Backfill locations from existing free-text storage_location values.
+    # GROUP BY name_key so case variants ("Drybox 1" / "DRYBOX 1") collapse to
+    # one row; INSERT OR IGNORE / ON CONFLICT keeps the migration idempotent.
+    _location_backfill_sql = (
+        """
+        INSERT OR IGNORE INTO locations (name, name_key, created_at, updated_at)
+        SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+        FROM spool
+        WHERE TRIM(COALESCE(storage_location, '')) != ''
+        GROUP BY LOWER(TRIM(storage_location))
+        """
+        if is_sqlite()
+        else """
+        INSERT INTO locations (name, name_key, created_at, updated_at)
+        SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+        FROM spool
+        WHERE TRIM(COALESCE(storage_location, '')) != ''
+        GROUP BY LOWER(TRIM(storage_location))
+        ON CONFLICT (name_key) DO NOTHING
+        """
+    )
+    async with conn.begin_nested():
+        await conn.execute(text(_location_backfill_sql))
+        await conn.execute(
+            text(
+                """
+                UPDATE spool
+                SET location_id = (
+                    SELECT l.id FROM locations l
+                    WHERE l.name_key = LOWER(TRIM(spool.storage_location))
+                    LIMIT 1
+                )
+                WHERE TRIM(COALESCE(storage_location, '')) != ''
+                  AND location_id IS NULL
+                """
+            )
+        )
+
+    # Sanity check: any spools that still have a free-text storage_location
+    # but no location_id link mean a row slipped through the dedup INSERT
+    # (most likely a pre-existing manually-inserted locations row with a
+    # hostile name shape that the UNIQUE(name) check tripped on). Surface
+    # the count so ops can investigate — the user won't see those spools in
+    # location-filtered queries until they're manually linked or re-saved.
+    orphan_count_row = await conn.execute(
+        text("SELECT COUNT(*) FROM spool WHERE TRIM(COALESCE(storage_location, '')) != '' AND location_id IS NULL")
+    )
+    orphan_count = orphan_count_row.scalar() or 0
+    if orphan_count:
+        logger.warning(
+            "Storage-location migration left %d spool(s) with free-text storage_location "
+            "but no location_id link. Re-save those spools or merge the orphaned location "
+            "names manually.",
+            orphan_count,
+        )
+
+    # Migration: Add on_ai_failure_detection column to notification_providers (#1794).
+    # Splits Obico AI failure detection out of the multiplexed on_printer_error
+    # event so users can subscribe to spaghetti alerts independently of HMS
+    # hardware-error alerts. Postgres rejects `DEFAULT 0` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT 0",
+        )
+    else:
+        await _safe_execute(
+            conn,
+            "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
+        )
+
+    # Migration: Add gate_acknowledged column to print_queue (#1818). Cleared
+    # by the per-printer "Resume after failure" action so the scheduler's
+    # `_check_previous_success` lookback skips this row. Postgres rejects
+    # `DEFAULT 0` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
+
+    # Migration: Add is_autologin column to oidc_providers (#1589). Postgres
+    # rejects ``DEFAULT 0`` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
+
+    # Migration: Disambiguate the four ``user_print_*`` notification template
+    # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
+    await _migrate_rename_user_print_template_names(conn)
+
+
+_USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
+    ("user_print_start", "User Print Started", "User Print Started Email"),
+    ("user_print_complete", "User Print Completed", "User Print Completed Email"),
+    ("user_print_failed", "User Print Failed", "User Print Failed Email"),
+    ("user_print_stopped", "User Print Stopped", "User Print Stopped Email"),
+)
+
+
+async def _migrate_rename_user_print_template_names(conn) -> None:
+    """Append " Email" to the four ``user_print_*`` notification template names (#1792).
+
+    The provider-level "Print Completed" and the per-user "User Print Completed"
+    rows were visually indistinguishable in the Message Templates list because
+    the seed name lacked the suffix that the EVENT_NAMES display map in
+    routes/notification_templates.py already uses ("User Print Completed Email").
+
+    Renames only rows where ``name`` is still the old default — admins who
+    renamed the template themselves keep their custom name. Standard SQL
+    UPDATE works on both SQLite and Postgres.
+    """
+    from sqlalchemy import text
+
+    async with conn.begin_nested():
+        for event_type, old_name, new_name in _USER_PRINT_TEMPLATE_RENAMES:
+            await conn.execute(
+                text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
+                {"new": new_name, "et": event_type, "old": old_name},
+            )
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 102 - 0
backend/app/core/local_config.py

@@ -0,0 +1,102 @@
+"""
+Small readers for appliance-set state files.
+
+Two distinct surfaces, same shape (defensive, silent on missing files,
+side-effect-free):
+
+- ``read_local_toml`` reads ``/etc/bambuddy/local.toml`` (the file the
+  appliance setup wizard writes during firstboot with the user's hostname,
+  timezone, and locale).
+- ``read_ntp_gate`` reads ``/run/bambuddy/time-synced`` (the appliance's
+  ntp-gate.sh signals time-sync state here once chrony reports sync, or
+  when the 3-minute timeout elapses with a "warning" marker).
+
+Universal across install shapes:
+
+- On the Bambuddy Appliance: both files exist by the time bambuddy.service
+  starts; we surface their values to the frontend.
+- On Docker / manual installs: both files are absent; we degrade silently.
+
+These readers are read-only and side-effect-free. They do NOT call
+hostnamectl / timedatectl / chronyc — system-state changes are the
+appliance's firstboot.sh responsibility (root, runs before this process
+exists). Here we just expose state so the frontend can render accordingly.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+from typing import Literal, TypedDict
+
+import tomllib
+
+log = logging.getLogger(__name__)
+
+DEFAULT_PATH = Path("/etc/bambuddy/local.toml")
+DEFAULT_NTP_GATE_PATH = Path("/run/bambuddy/time-synced")
+
+# Three states: synced ("ok"), gated-and-timed-out ("warning"), or unknown (None).
+TimeSyncState = Literal["ok", "warning"] | None
+
+
+class LocalConfig(TypedDict, total=False):
+    hostname: str
+    timezone: str
+    locale: str
+
+
+def read_local_toml(path: Path = DEFAULT_PATH) -> LocalConfig:
+    """Read the appliance local.toml. Missing / invalid file returns empty dict.
+
+    Only the keys actually present in the file are returned — the caller checks
+    `if "locale" in config:` rather than relying on defaults. Non-string values
+    are dropped with a warning to keep this defensive on a hand-edited file.
+    """
+    if not path.is_file():
+        return {}
+    try:
+        with path.open("rb") as f:
+            data = tomllib.load(f)
+    except (OSError, tomllib.TOMLDecodeError) as exc:
+        log.warning("local.toml at %s could not be parsed: %s", path, exc)
+        return {}
+
+    result: LocalConfig = {}
+    for key in ("hostname", "timezone", "locale"):
+        value = data.get(key)
+        if value is None:
+            continue
+        if not isinstance(value, str):
+            log.warning("local.toml: %r is %s, expected str — ignoring", key, type(value).__name__)
+            continue
+        result[key] = value  # type: ignore[literal-required]
+    return result
+
+
+def read_ntp_gate(path: Path = DEFAULT_NTP_GATE_PATH) -> TimeSyncState:
+    """Read the appliance NTP gate file. Returns "ok", "warning", or None.
+
+    Wire contract with bambuddy-appliance/firstboot/ntp-gate.sh:
+      - File absent: gate hasn't been evaluated yet, or this isn't an appliance
+        install. Caller should treat as "unknown / don't gate."
+      - File content starts with "ok": chrony reported sync within 3 minutes.
+      - File content starts with "warning": 3-minute timeout elapsed without
+        sync. The user has already waited and the wizard proceeded with a
+        degraded clock — auth tokens may have incorrect expiry, TLS certs may
+        fail validation. UI should surface this.
+      - Anything else: defensive fall-through to None.
+    """
+    try:
+        body = path.read_text(errors="replace").strip()
+    except FileNotFoundError:
+        return None
+    except OSError as exc:
+        log.warning("ntp-gate file at %s could not be read: %s", path, exc)
+        return None
+
+    if body.startswith("ok"):
+        return "ok"
+    if body.startswith("warning"):
+        return "warning"
+    return None

+ 9 - 5
backend/app/core/permissions.py

@@ -19,7 +19,7 @@ class Permission(StrEnum):
     PRINTERS_CREATE = "printers:create"
     PRINTERS_UPDATE = "printers:update"
     PRINTERS_DELETE = "printers:delete"
-    PRINTERS_CONTROL = "printers:control"  # Start/stop/pause/resume prints
+    PRINTERS_CONTROL = "printers:control"  # Printer controls: stop/pause/resume, lights, motors, drying, etc.
     PRINTERS_FILES = "printers:files"  # Send files to printer
     PRINTERS_AMS_RFID = "printers:ams_rfid"  # Re-read AMS RFID tags
     PRINTERS_CLEAR_PLATE = "printers:clear_plate"  # Confirm plate cleared for next print
@@ -36,15 +36,15 @@ class Permission(StrEnum):
     ARCHIVES_UPDATE_ALL = "archives:update_all"
     ARCHIVES_DELETE_OWN = "archives:delete_own"
     ARCHIVES_DELETE_ALL = "archives:delete_all"
-    ARCHIVES_REPRINT_OWN = "archives:reprint_own"
-    ARCHIVES_REPRINT_ALL = "archives:reprint_all"
+    ARCHIVES_REPRINT_OWN = "archives:reprint_own"  # Reprint own archives; queue:create is also required to enqueue
+    ARCHIVES_REPRINT_ALL = "archives:reprint_all"  # Reprint any archive; queue:create is also required to enqueue
     ARCHIVES_PURGE = "archives:purge"
 
     # Queue
     QUEUE_READ = "queue:read"
     QUEUE_READ_OWN = "queue:read_own"
     QUEUE_READ_ALL = "queue:read_all"
-    QUEUE_CREATE = "queue:create"
+    QUEUE_CREATE = "queue:create"  # Create queue items, including ASAP items eligible for immediate dispatch
     QUEUE_UPDATE_OWN = "queue:update_own"
     QUEUE_UPDATE_ALL = "queue:update_all"
     QUEUE_DELETE_OWN = "queue:delete_own"
@@ -55,7 +55,7 @@ class Permission(StrEnum):
     LIBRARY_READ = "library:read"
     LIBRARY_READ_OWN = "library:read_own"
     LIBRARY_READ_ALL = "library:read_all"
-    LIBRARY_UPLOAD = "library:upload"
+    LIBRARY_UPLOAD = "library:upload"  # Upload/import/slice library files; queue:create is also required to print
     LIBRARY_UPDATE_OWN = "library:update_own"
     LIBRARY_UPDATE_ALL = "library:update_all"
     LIBRARY_DELETE_OWN = "library:delete_own"
@@ -133,6 +133,7 @@ class Permission(StrEnum):
 
     # AMS History
     AMS_HISTORY_READ = "ams_history:read"
+    PRINTER_SENSOR_HISTORY_READ = "printer_sensor_history:read"
 
     # Stats/Metrics
     STATS_READ = "stats:read"
@@ -295,6 +296,7 @@ PERMISSION_CATEGORIES = {
     ],
     "Stats & History": [
         Permission.AMS_HISTORY_READ,
+        Permission.PRINTER_SENSOR_HISTORY_READ,
         Permission.STATS_READ,
         Permission.STATS_FILTER_BY_USER,
     ],
@@ -445,6 +447,7 @@ DEFAULT_GROUPS = {
             Permission.FIRMWARE_READ.value,
             # Stats & History
             Permission.AMS_HISTORY_READ.value,
+            Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             # Settings - read only
@@ -476,6 +479,7 @@ DEFAULT_GROUPS = {
             Permission.EXTERNAL_LINKS_READ.value,
             Permission.FIRMWARE_READ.value,
             Permission.AMS_HISTORY_READ.value,
+            Permission.PRINTER_SENSOR_HISTORY_READ.value,
             Permission.STATS_READ.value,
             Permission.SYSTEM_READ.value,
             Permission.SETTINGS_READ.value,

+ 9009 - 0
backend/app/data/hms_actions.json

@@ -0,0 +1,9009 @@
+{
+    "31B": {
+        "07008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "18008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1802802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1807802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1804802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18078029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18068026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1805802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18068029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18048029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1803802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "0703802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1806802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0701802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0700802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "1801802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0702802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18048026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18078026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "1800802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008065": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008042": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0700860000020002": [],
+        "03004031": [],
+        "03004035": [],
+        "03004036": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0502402C": [
+            "OK_BUTTON"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "07FF200000020002": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FE200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "18FE200000020002": [],
+        "0702200000020001": [],
+        "05008098": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF200000020001": [],
+        "0702230000020001": [],
+        "18FE200000020001": [],
+        "0702210000020001": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "18FF200000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0C00030000030008": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FE200000020001": [],
+        "18FF200000020002": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18058028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008051": [],
+        "07018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18048028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "18028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18068028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18078028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008055": [],
+        "07018037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07048033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "05008093": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806F": [],
+        "05024019": [
+            "OK_BUTTON"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806B": [],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008024": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05004047": [
+            "OK_BUTTON"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500040000030054": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "05008071": [],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "03008025": [
+            "RESUME_PRINTING"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "07FFC012": [],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008067": [],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "07038034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07008018": [
+            "RESUME_PRINTING"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020037": [],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "0500040000020031": [],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008073": [],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "05024023": [
+            "OK_JUMP_RACK"
+        ],
+        "18038033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05024021": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "0300802A": [
+            "RESUME_PRINTING",
+            "CHECK_ASSISTANT"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0501040000030010": [],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05028022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008018": [
+            "RESUME_PRINTING"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008026": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05024017": [
+            "OK_JUMP_RACK"
+        ],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008063": [],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0500807C": [],
+        "07FEC011": [],
+        "05008056": [],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07058037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "0500040000020036": [],
+        "07008036": [
+            "REMOVE_CLOSE_BTN",
+            "RETRY_PROBLEM_SOLVED",
+            "ABORT"
+        ],
+        "0500806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0501040000030004": [],
+        "0500040000020041": [],
+        "05008077": [],
+        "05008078": [],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC012": [],
+        "1A004007": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "0500806C": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18018030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700550000020001": [],
+        "18028034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07028030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008029": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008058": [],
+        "0501040000030002": [],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "18058033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807D": [],
+        "18038030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "03008071": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05024018": [
+            "OK_BUTTON"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "18028030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008084": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "18038034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07048037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500040000020034": [],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008082": [],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07018033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020035": [],
+        "03008081": [],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008053": [
+            "OK_BUTTON"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500807B": [],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1A00120000020010": [],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05004046": [
+            "OK_BUTTON"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030006": [],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008074": [],
+        "18048034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "1A008004": [
+            "OK_BUTTON"
+        ],
+        "03008023": [
+            "RESUME_PRINTING"
+        ],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008083": [],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07058034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07048034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC011": [],
+        "18018033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008059": [],
+        "05008079": [],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07018030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807E": [],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "05008066": [],
+        "05008068": [],
+        "07008033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "07008035": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300802B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1A004008": [
+            "OK_BUTTON"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "1A004009": [],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805C": [],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0500805A": [],
+        "18008030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030005": [],
+        "07008032": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008031": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07028037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "05008090": [],
+        "18058034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C00040000030024": [],
+        "07058033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008064": [],
+        "18048033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "18028033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05024016": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07028033": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "07038037": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018034": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008027": [
+            "RESUME_PRINTING"
+        ],
+        "1A008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "20P": {
+        "07008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1805700000020007": [],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "18028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1802802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701220000020001": [],
+        "18058028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004035": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "03008051": [],
+        "18038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18008029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1807802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004036": [],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "18078027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1802700000020007": [],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0502802B": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0703220000020001": [],
+        "07018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "07038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05028037": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1804802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "18078029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18068026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18038028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700200000020001": [],
+        "18008028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1805802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004037": [],
+        "0702700000020007": [],
+        "18068029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0702220000020001": [],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0700700000020007": [],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "0702200000020001": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07FEC012": [],
+        "18018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "0700550000020001": [],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18048028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502C033": [
+            "PROCEED",
+            "DONT_REMIND_NEXT_TIME"
+        ],
+        "0501040000030002": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05024029": [
+            "OK_BUTTON"
+        ],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "18048029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC011": [],
+        "0702230000020001": [],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "18028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "05004045": [
+            "OK_BUTTON"
+        ],
+        "07FFC012": [],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1803802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05024035": [
+            "OK_BUTTON"
+        ],
+        "0702210000020001": [],
+        "07FEC011": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004030": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07008026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0703210000020001": [],
+        "03004038": [],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF800D": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT",
+            "STOP_PRINTING"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "1806802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0300C012": [
+            "OK_BUTTON",
+            "IGNORE_NO_REMINDER_NEXT_TIME"
+        ],
+        "0701802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701230000020001": [],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "0700230000020001": [],
+        "03004032": [],
+        "0700210000020001": [],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "18038027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004033": [],
+        "1800700000020007": [],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "18028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "1804700000020007": [],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0700802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18058027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "0700860000020002": [],
+        "18038029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0700220000020001": [],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "1801802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18058029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0702802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18018026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18068027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07018029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0703200000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0502802A": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "18048026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18078026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8017": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "05028036": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078028": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "1806700000020007": [],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1800802A": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0501040000030003": [],
+        "07028029": [
+            "CONTINUE",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18048027": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "0701210000020001": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "07028026": [
+            "CHECK_ASSISTANT",
+            "RETRY_PROBLEM_SOLVED"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801700000020007": [],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1807700000020007": [],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0703700000020007": [],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ]
+    },
+    "094": {
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008065": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008042": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004036": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "07FF200000020002": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FE200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "18FE200000020002": [],
+        "0702200000020001": [],
+        "05008098": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF200000020001": [],
+        "0702230000020001": [],
+        "18FE200000020001": [],
+        "0702210000020001": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "18FF200000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0C00030000030008": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FE200000020001": [],
+        "18FF200000020002": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008051": [],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "03008071": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008053": [
+            "OK_BUTTON"
+        ],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807E": [],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500040000030054": [],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0501040000030010": [],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0501040000030004": [],
+        "0501040000030002": [],
+        "03008081": [],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05024005": [],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "05FF8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008090": [],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008077": [],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "05008055": [],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0500806F": [],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "0500806B": [],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "05008071": [],
+        "05008067": [],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "0500806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0500040000020037": [],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020031": [],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008073": [],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FEC011": [],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008063": [],
+        "0500807C": [],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008056": [],
+        "0500040000020041": [],
+        "0500040000020036": [],
+        "05008078": [],
+        "0700550000020001": [],
+        "0500806C": [],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008058": [],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807D": [],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008082": [],
+        "0500040000020034": [],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0500807B": [],
+        "0500040000020035": [],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008074": [],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "07FFC012": [],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "05008083": [],
+        "07FEC012": [],
+        "05008059": [],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "05008079": [],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008066": [],
+        "05008068": [],
+        "0500805C": [],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0500805A": [],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C00040000030024": [],
+        "05008064": [],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "07FFC011": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500806E": []
+    },
+    "239": {
+        "18FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8020": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "07018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028021": [
+            "RETRY_PROBLEM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "05008065": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008042": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FE8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004036": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "07FF200000020002": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FE200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "18FE200000020002": [],
+        "0702200000020001": [],
+        "05008098": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF200000020001": [],
+        "0702230000020001": [],
+        "18FE200000020001": [],
+        "0702210000020001": [],
+        "18FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "18FF200000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0C00030000030008": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FE200000020001": [],
+        "18FF200000020002": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008051": [],
+        "18FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "18FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C00803F": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "03008071": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008053": [
+            "OK_BUTTON"
+        ],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807E": [],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "07FE8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "05008055": [],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "0500806F": [],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806B": [],
+        "05008071": [],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "07FE8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0500040000030054": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05008067": [],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC010": [
+            "CONTINUE"
+        ],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020037": [],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020031": [],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "0500806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008073": [],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008063": [],
+        "07FEC011": [],
+        "0501040000030010": [],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807C": [],
+        "05FF8053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "18FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008056": [],
+        "07FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FEC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020041": [],
+        "05008077": [],
+        "0500040000020036": [],
+        "0501040000030004": [],
+        "0500806C": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008078": [],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008058": [],
+        "0501040000030002": [],
+        "0700550000020001": [],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500807D": [],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FE8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008082": [],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500040000020034": [],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500807B": [],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8002": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020035": [],
+        "03008081": [],
+        "07FF8013": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008074": [],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC006": [
+            "CONTINUE"
+        ],
+        "18FE8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC012": [],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500C032": [
+            "OK_BUTTON"
+        ],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008083": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FEC012": [],
+        "05008059": [],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008079": [],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "05008069": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "05FF8069": [
+            "REFRESH_NOZZLE"
+        ],
+        "05008066": [],
+        "05008068": [],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805C": [],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805A": [],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "07FE8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FEC009": [
+            "CONTINUE"
+        ],
+        "05008090": [],
+        "18FE8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C00040000030024": [],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008040": [
+            "RESUME_PRINTING_DEFECTS",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "05008064": [],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FE8012": [
+            "RESUME_PRINTING"
+        ],
+        "07FE8006": [
+            "CONTINUE"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "07FFC011": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "00W": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008006": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0501040000030001": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008002": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "00M": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008006": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0501040000030001": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008002": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "03W": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008006": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0501040000030001": [],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008002": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0300801B": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "01S": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "01P": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008004": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "18FF200000020002": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "07FF200000020001": [],
+        "07FF200000020002": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "18FF200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "0500402F": [],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ]
+    },
+    "093": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C080": [
+            "OK_BUTTON"
+        ],
+        "03008070": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "03008055": [],
+        "05008098": [],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004037": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0703220000020001": [],
+        "18FF200000020002": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0500806D": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0702230000020001": [],
+        "18FF200000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "07FF200000020002": [],
+        "0701210000020001": [],
+        "07FF200000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "05008099": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008051": [],
+        "050080A6": [
+            "OK_BUTTON"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008064": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008053": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008096": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "050080A7": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008097": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03002E0000030001": [],
+        "03008059": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "03008058": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008057": [
+            "CHECK_ASSISTANT"
+        ],
+        "05004045": [
+            "OK_BUTTON"
+        ],
+        "05008048": [
+            "STOP_PRINTING"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "05008086": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008054": [
+            "OK_BUTTON"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "05008055": [],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008048": [
+            "OK_BUTTON"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008050": [
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008047": [
+            "OK_BUTTON"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806F": [],
+        "0500806B": [],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000030054": [],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "03008080": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300807D": [
+            "OK_BUTTON"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030004": [],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "05008071": [],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "05008067": [],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C004041": [
+            "OK_BUTTON"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020037": [],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020031": [],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008044": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05008073": [],
+        "0C004020": [
+            "OK_BUTTON"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500805B": [],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "0501040000030010": [],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300C056": [
+            "TURN_OFF_FIRE_ALARM",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0C004029": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008063": [],
+        "0500807C": [],
+        "05008056": [],
+        "0C00800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020036": [],
+        "0500040000020041": [],
+        "05008077": [],
+        "05008078": [],
+        "0500806C": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008058": [],
+        "0300804B": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500807D": [],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC012": [],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0500040000020034": [],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008082": [],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500807B": [],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500040000020035": [],
+        "03008081": [],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008049": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008074": [],
+        "03008045": [
+            "OK_BUTTON"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008083": [],
+        "0300807F": [
+            "CHECK_ASSISTANT"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008059": [],
+        "0500403D": [
+            "OK_BUTTON"
+        ],
+        "07FFC011": [],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "05008079": [],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0500807E": [],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008068": [],
+        "0C004022": [
+            "OK_BUTTON"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008066": [],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0502C00F": [
+            "OK_BUTTON"
+        ],
+        "0300404B": [
+            "OK_BUTTON"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500805C": [],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500805A": [],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0300C070": [
+            "OK_BUTTON"
+        ],
+        "0500808F": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008090": [],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "0C00040000030024": [],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008064": [],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "0C008033": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "050080A0": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "039": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12018011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12028011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12008011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "03008015": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "1200210000020001": [],
+        "18FF200000020002": [],
+        "12FF200000020001": [],
+        "1200220000020001": [],
+        "1200230000020001": [],
+        "1200200000020001": [],
+        "07FF200000020001": [],
+        "18FF200000020001": [],
+        "07FF200000020002": [],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500402F": [],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "12008006": [
+            "CONTINUE"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8010": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "050040A4": [
+            "OK_BUTTON"
+        ],
+        "050040A5": [
+            "OK_BUTTON"
+        ],
+        "12008012": [
+            "RESUME_PRINTING"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "12008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8006": [
+            "CONTINUE"
+        ],
+        "12008010": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008015": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC006": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8003": [
+            "CONTINUE"
+        ],
+        "12FFC003": [
+            "CONTINUE"
+        ],
+        "12008014": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "12008016": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500403A": [
+            "OK_BUTTON"
+        ]
+    },
+    "030": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12018011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12028011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12008011": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "03008015": [
+            "LOAD_VIRTUAL_TRAY",
+            "FILAMENT_LOAD_RESUME"
+        ],
+        "1200210000020001": [],
+        "18FF200000020002": [],
+        "12FF200000020001": [],
+        "1200220000020001": [],
+        "1200230000020001": [],
+        "1200200000020001": [],
+        "07FF200000020001": [],
+        "18FF200000020001": [],
+        "07FF200000020002": [],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0703210000020001": [],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "0701210000020001": [],
+        "1807700000020007": [],
+        "1801700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "05004015": [],
+        "0500402E": [],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500402F": [],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "12008006": [
+            "CONTINUE"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8010": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018004": [
+            "CONTINUE"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07004001": [
+            "OK_BUTTON"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "050040A4": [
+            "OK_BUTTON"
+        ],
+        "050040A5": [
+            "OK_BUTTON"
+        ],
+        "12008012": [
+            "RESUME_PRINTING"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "12008001": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8006": [
+            "CONTINUE"
+        ],
+        "12008010": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008015": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "12FFC006": [
+            "CONTINUE"
+        ],
+        "03008010": [
+            "CHECK_ASSISTANT"
+        ],
+        "12FF8003": [
+            "CONTINUE"
+        ],
+        "12FFC003": [
+            "CONTINUE"
+        ],
+        "12008014": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008008": [
+            "CHECK_ASSISTANT"
+        ],
+        "12008016": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ]
+    },
+    "22E": {
+        "03008019": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "1801C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1805C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1802C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1803C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1800C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1804C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1807C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "1806C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004039": [],
+        "07028016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07038016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0502C032": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "07028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18028011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18068011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18078011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18058011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18048011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008003": [
+            "RESUME_PRINTING_DEFECTS",
+            "STOP_PRINTING"
+        ],
+        "07038011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018011": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03004031": [],
+        "03004035": [],
+        "03004037": [],
+        "03004030": [],
+        "03004038": [],
+        "03004032": [],
+        "03004033": [],
+        "0500808C": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0500809B": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05008081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FF8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "05FE8081": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "03008062": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008063": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "1805700000020007": [],
+        "0701220000020001": [],
+        "1802700000020007": [],
+        "0703220000020001": [],
+        "07FF200000020001": [],
+        "0700200000020001": [],
+        "0702700000020007": [],
+        "0702220000020001": [],
+        "0700700000020007": [],
+        "0702200000020001": [],
+        "18008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0702230000020001": [],
+        "0702210000020001": [],
+        "0703230000020001": [],
+        "0701200000020001": [],
+        "0C008043": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0703210000020001": [],
+        "07FFC009": [
+            "CONTINUE"
+        ],
+        "0701230000020001": [],
+        "0700230000020001": [],
+        "0700210000020001": [],
+        "18FF200000020002": [],
+        "1800700000020007": [],
+        "0701700000020007": [],
+        "1803700000020007": [],
+        "1804700000020007": [],
+        "0700220000020001": [],
+        "07FF200000020002": [],
+        "18018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0703200000020001": [],
+        "1806700000020007": [],
+        "18FF200000020001": [],
+        "0701210000020001": [],
+        "1801700000020007": [],
+        "1807700000020007": [],
+        "0703700000020007": [],
+        "0300401F": [
+            "OK_BUTTON"
+        ],
+        "0500809A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "IGNORE_RESUME"
+        ],
+        "03008051": [],
+        "05008072": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05008084": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03008041": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C059": [
+            "OK_BUTTON"
+        ],
+        "05008054": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8010": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "03008014": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008093": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "03002E0000030001": [],
+        "05004045": [
+            "OK_BUTTON"
+        ],
+        "0500403C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "07FFC006": [
+            "CONTINUE"
+        ],
+        "07018010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "0C008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0C004026": [
+            "OK_BUTTON"
+        ],
+        "03008005": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07008002": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC00A": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "18018004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0500C036": [
+            "OK_BUTTON"
+        ],
+        "0300801C": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07018005": [
+            "CONTINUE"
+        ],
+        "03008046": [
+            "OK_BUTTON"
+        ],
+        "03004016": [
+            "CHECK_ASSISTANT"
+        ],
+        "07004025": [
+            "OK_BUTTON"
+        ],
+        "0700C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "18018005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300C012": [
+            "OK_BUTTON"
+        ],
+        "18008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008051": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "03004011": [
+            "OK_BUTTON"
+        ],
+        "05008061": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05FF8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "18008012": [
+            "RESUME_PRINTING"
+        ],
+        "07008006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008022": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07028006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC011": [],
+        "18008004": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008062": [
+            "IGNORE_RESUME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "07FFC010": [
+            "CONTINUE"
+        ],
+        "07018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18018007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07008010": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "18018003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030002": [],
+        "0C008018": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05008080": [
+            "REFRESH_NOZZLE"
+        ],
+        "0500807A": [
+            "CHECK_ASSISTANT"
+        ],
+        "0300804F": [
+            "OK_BUTTON"
+        ],
+        "0500808D": [
+            "IGNORE_RESUME"
+        ],
+        "05024005": [],
+        "0500808B": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "05008088": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "07018012": [
+            "RESUME_PRINTING"
+        ],
+        "18008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0701C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "0C00403D": [
+            "CHECK_ASSISTANT",
+            "OK_BUTTON"
+        ],
+        "0300804E": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0502400E": [
+            "OK_BUTTON"
+        ],
+        "07FF8007": [
+            "FILAMENT_EXTRUDED",
+            "RETRY_FILAMENT_EXTRUDED"
+        ],
+        "05008091": [
+            "IGNORE_RESUME"
+        ],
+        "0703C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "03008021": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "07038006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008040": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0500403E": [
+            "OK_BUTTON"
+        ],
+        "07008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "03008042": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "18008003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC012": [],
+        "0300801E": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500808A": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300800A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05FE8080": [
+            "REFRESH_NOZZLE"
+        ],
+        "07FFC008": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "18018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300800B": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "18018012": [
+            "RESUME_PRINTING"
+        ],
+        "07018016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8025": [
+            "CHECK_ASSISTANT",
+            "CONTINUE"
+        ],
+        "07FF8006": [
+            "CONTINUE"
+        ],
+        "05008087": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "05FF806A": [
+            "IGNORE_NO_REMINDER_NEXT_TIME",
+            "PROBLEM_SOLVED_RESUME"
+        ],
+        "0C008034": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "07008012": [
+            "RESUME_PRINTING"
+        ],
+        "03004000": [
+            "CHECK_ASSISTANT"
+        ],
+        "03004002": [
+            "CHECK_ASSISTANT"
+        ],
+        "0500803C": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0702C069": [
+            "CHECK_ASSISTANT"
+        ],
+        "07018006": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "05008085": [
+            "PROBLEM_SOLVED_RESUME",
+            "IGNORE_RESUME"
+        ],
+        "0C004021": [
+            "OK_BUTTON"
+        ],
+        "07FFC003": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FF8005": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0501040000030003": [],
+        "03008061": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0300801A": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "0500806E": [],
+        "05024003": [
+            "OK_BUTTON"
+        ],
+        "18008007": [
+            "CHECK_ASSISTANT",
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "0C008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "CHECK_ASSISTANT"
+        ],
+        "05004070": [
+            "OK_BUTTON"
+        ]
+    },
+    "default": {
+        "07FF8030": [
+            "CONTINUE"
+        ],
+        "07FE8030": [
+            "CONTINUE"
+        ],
+        "07FEC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "07FFC030": [
+            "CONTINUE",
+            "CHECK_ASSISTANT"
+        ],
+        "0300806F": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "0500040000030057": [
+            "DISABLE_PURIFICATION"
+        ],
+        "0502C031": [
+            "PROCEED",
+            "CANCLE"
+        ],
+        "0300806E": [
+            "CHECK_ASSISTANT",
+            "REMOVE_CLOSE_BTN"
+        ],
+        "05004095": [],
+        "05008057": [
+            "STOP_PRINTING",
+            "IGNORE_RESUME"
+        ],
+        "0502C028": [
+            "OK_BUTTON"
+        ],
+        "0502C026": [
+            "OK_BUTTON"
+        ],
+        "0500400E": [
+            "OK_BUTTON"
+        ],
+        "05004037": [
+            "OK_BUTTON"
+        ],
+        "0502C014": [
+            "OK_BUTTON"
+        ],
+        "0502C012": [
+            "OK_BUTTON"
+        ],
+        "05008092": [
+            "IGNORE_RESUME",
+            "STOP_PRINTING"
+        ],
+        "05004042": [
+            "CANCLE"
+        ],
+        "05004040": [
+            "OK_BUTTON"
+        ],
+        "05004007": [
+            "OK_BUTTON"
+        ],
+        "0502C010": [
+            "STOP_DRYING"
+        ],
+        "05004041": [
+            "OK_BUTTON"
+        ],
+        "05004043": [
+            "OK_BUTTON"
+        ],
+        "03008000": [
+            "RESUME_PRINTING"
+        ],
+        "0300800C": [
+            "RESUME_PRINTING"
+        ],
+        "0300800D": [
+            "RESUME_PRINTING",
+            "CHECK_ASSISTANT"
+        ],
+        "03008017": [
+            "RESUME_PRINTING_PROBELM_SOLVED"
+        ],
+        "03008016": [
+            "RESUME_PRINTING_PROBELM_SOLVED",
+            "STOP_PRINTING",
+            "CHECK_ASSISTANT"
+        ],
+        "05004003": [
+            "OK_BUTTON"
+        ],
+        "0C00402D": [
+            "OK_BUTTON"
+        ],
+        "05024001": [
+            "OK_BUTTON"
+        ],
+        "05004004": [
+            "OK_BUTTON"
+        ],
+        "05004014": [
+            "OK_BUTTON"
+        ],
+        "03008013": [
+            "RESUME_PRINTING"
+        ],
+        "0C00402C": [
+            "OK_BUTTON"
+        ],
+        "03008007": [
+            "RESUME_PRINTING",
+            "STOP_PRINTING"
+        ]
+    }
+}

+ 576 - 42
backend/app/main.py

@@ -1,4 +1,5 @@
 import asyncio
+import json
 import logging
 import mimetypes as _mimetypes
 import os
@@ -22,7 +23,6 @@ from backend.app.api.routes import (
     archive_purge,
     archives,
     auth,
-    background_dispatch as background_dispatch_routes,
     bug_report,
     camera,
     cloud,
@@ -36,6 +36,7 @@ from backend.app.api.routes import (
     kprofiles,
     labels,
     library,
+    library_tags,
     library_trash,
     local_backup,
     local_presets,
@@ -50,12 +51,14 @@ from backend.app.api.routes import (
     pending_uploads,
     print_log,
     print_queue,
+    printer_sensor_history,
     printers,
     projects,
     settings as settings_routes,
     slice_jobs,
     slicer_presets,
     smart_plugs,
+    sponsor_prompt,
     spoolbuddy,
     spoolman,
     spoolman_inventory,
@@ -77,7 +80,6 @@ from backend.app.core.websocket import ws_manager
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive_purge import archive_purge_service
-from backend.app.services.background_dispatch import background_dispatch
 from backend.app.services.bambu_ftp import (
     FileNotOnPrinterError,
     cache_3mf_download,
@@ -343,6 +345,15 @@ _active_prints: dict[tuple[int, str], int] = {}
 # nozzle parking on slicer profiles with Timelapse Type = Smooth).
 _stage22_finish_frames: dict[int, bytes] = {}
 
+# #1790: per-printer producer-done event. Set by `on_finish_photo_moment` in its
+# `finally` block (whether it captured a frame or not). The consumer in
+# `_background_finish_photo` waits on it before reading `_stage22_finish_frames`
+# so the FINISH-state fallback path — where moment and completion are dispatched
+# back-to-back — doesn't race past the producer with an empty pop, and the
+# consumer's RTSP fallback can't collide with the producer's still-in-flight RTSP
+# grab (Bambu printers allow only one RTSP client at a time).
+_stage22_finish_in_flight: dict[int, asyncio.Event] = {}
+
 # Per-printer "connected" edge tracker. Used by `on_printer_status_change`
 # to fire `reconcile_stale_active_prints` exactly once per (re)connection
 # (#1542 follow-up — power-cycle ghost prints). The value is True after
@@ -395,6 +406,22 @@ _bed_cool_waiters: dict[int, dict] = {}
 # as "cancelled" (stopped by user) so the correct notification email is sent.
 _user_stopped_printers: set[int] = set()
 
+# Offline-notification edge state (#1752): fire `on_printer_offline` exactly
+# once when a printer transitions connected → disconnected. `_printer_last_connected`
+# holds the previous observation so we only fire on the True → False edge (a
+# False → False repeat doesn't notify; an initial False at startup doesn't
+# notify either, since there's no prior True). `_printer_offline_notify_tasks`
+# holds the per-printer pending asyncio task that fires the notification
+# after a debounce window — cancelled if the printer reconnects before the
+# window elapses, so transient MQTT blips don't flood the user.
+_printer_last_connected: dict[int, bool] = {}
+_printer_offline_notify_tasks: dict[int, asyncio.Task] = {}
+# Debounce: a printer must stay offline this long before we notify. Sized
+# against the staleness path (`bambu_mqtt.py::STALE_RECONNECT_COOLDOWN = 30s`)
+# so a single stale-trigger cooldown isn't enough to fire — only a real
+# offline that survives one reconnect attempt notifies.
+_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS = 60.0
+
 
 # HMS short-code → human-readable failure reason. Used by _dispatch_archive_update
 # when status="failed" to label the print's failure_reason in archives.
@@ -500,6 +527,76 @@ def _get_ams_assignment_lock(printer_id: int) -> asyncio.Lock:
     return lock
 
 
+# Per-printer dedup for unknown_tag WS broadcasts. Keyed by
+# (ams_id, tray_id) -> (tag_uid, tray_uuid); we only re-broadcast when the
+# tag tuple changes for the slot. Cleared when the slot is reported empty
+# so remove + reinsert reliably re-prompts the UI.
+_unknown_tag_last_broadcast: dict[int, dict[tuple[int, int], tuple[str, str]]] = {}
+
+
+async def _broadcast_unknown_tag(
+    *,
+    printer_id: int,
+    ams_id: int,
+    tray_id: int,
+    tag_uid: str,
+    tray_uuid: str,
+    tray_type: str | None = None,
+    tray_color: str | None = None,
+    tray_sub_brands: str | None = None,
+    tray_count: int | None = None,
+) -> None:
+    """Broadcast unknown_tag, deduped so repeated MQTT pushes for the same slot+tag don't spam the UI."""
+    _logger = logging.getLogger(__name__)
+    slot_key = (ams_id, tray_id)
+    tag_key = (tag_uid or "", tray_uuid or "")
+    per_printer = _unknown_tag_last_broadcast.setdefault(printer_id, {})
+    if per_printer.get(slot_key) == tag_key:
+        _logger.debug(
+            "unknown_tag deduped for printer=%d AMS=%d slot=%d tag=%s",
+            printer_id,
+            ams_id,
+            tray_id,
+            tag_key[0][:8] or tag_key[1][:8] or "(none)",
+        )
+        return
+    _logger.info(
+        "unknown_tag broadcast: printer=%d AMS=%d slot=%d type=%r color=%r tag=%s",
+        printer_id,
+        ams_id,
+        tray_id,
+        tray_type,
+        tray_color,
+        tag_key[0][:8] or tag_key[1][:8] or "(none)",
+    )
+    # Broadcast first; only commit the dedup if the WS write succeeds.
+    # If broadcast raises, the next MQTT push retries instead of being
+    # permanently silenced by a poisoned dedup entry.
+    await ws_manager.broadcast(
+        {
+            "type": "unknown_tag",
+            "printer_id": printer_id,
+            "ams_id": ams_id,
+            "tray_id": tray_id,
+            "tag_uid": tag_uid,
+            "tray_uuid": tray_uuid,
+            "tray_type": tray_type,
+            "tray_color": tray_color,
+            "tray_sub_brands": tray_sub_brands,
+            "tray_count": tray_count,
+        }
+    )
+    per_printer[slot_key] = tag_key
+
+
+def _clear_unknown_tag_dedup(printer_id: int, ams_id: int, tray_id: int) -> None:
+    """Drop the cached last-broadcast tag for a slot (called when slot reports empty or gets matched)."""
+    per_printer = _unknown_tag_last_broadcast.get(printer_id)
+    if per_printer is None:
+        return
+    per_printer.pop((ams_id, tray_id), None)
+
+
 # TTL for expected-print entries: evict registrations older than this to prevent
 # unbounded growth when a print is registered but never starts (e.g. printer
 # disconnect, app restart, print started from the printer panel).
@@ -675,6 +772,82 @@ def _get_start_plate_id(archive_id: int | None) -> int | None:
     return _print_plate_ids.get(archive_id)
 
 
+def _partial_progress_scale(progress: int | float | None) -> float:
+    """Clamp ``progress / 100`` into [0.0, 1.0] for partial-print scaling.
+
+    Used by every site that multiplies a "would-have-used" slicer estimate
+    down to "actually-used" for failed / cancelled / stopped prints. Centralised
+    so the three sites in ``_background_notifications`` (and the per-plate
+    override helper) can't drift apart on the coercion shape.
+    """
+    return max(0.0, min((progress or 0) / 100.0, 1.0))
+
+
+def _scope_notification_archive_data_to_plate(
+    archive_data: dict,
+    archive_file_path: str | None,
+    plate_id: int | None,
+    print_status: str,
+    progress: int | float | None,
+    base_dir: Path,
+) -> dict:
+    """Override summed-across-plates totals in ``archive_data`` with the values
+    for ``plate_id`` so the completion notification reports what was actually
+    printed, not the whole project (#1785).
+
+    The 3MF parser at services/archive.py:200-264 sums ``prediction`` and
+    ``weight`` across every plate of a multi-plate file (#1593) — correct for
+    the archive card's "whole project" headline, wrong for the completion
+    notification of a single-plate print. The queue UI already re-reads the
+    3MF per-plate at print_queue.py:272-285; this helper mirrors that for the
+    notification payload (filament grams, time estimate, per-slot breakdown).
+
+    No-ops when ``plate_id`` is None, the file is missing, or the 3MF carries
+    no per-plate values — in every fail case the original ``archive_data`` is
+    returned unchanged so the notification still sends.
+    """
+    if plate_id is None or not archive_file_path:
+        return archive_data
+
+    from backend.app.utils.threemf_tools import (
+        extract_filament_usage_from_3mf,
+        extract_print_time_from_3mf,
+    )
+
+    archive_path = base_dir / archive_file_path
+    if not archive_path.exists():
+        return archive_data
+
+    plate_slots = extract_filament_usage_from_3mf(archive_path, plate_id)
+    plate_grams = sum(f.get("used_g", 0) for f in plate_slots)
+    plate_time = extract_print_time_from_3mf(archive_path, plate_id)
+
+    scale = 1.0 if print_status == "completed" else _partial_progress_scale(progress)
+
+    if plate_time:
+        archive_data["print_time_seconds"] = plate_time
+
+    # Gate both the grams headline AND the per-slot breakdown on the same
+    # `plate_grams > 0` signal: if the 3MF carries per-plate filament rows but
+    # they all sum to zero (slicer bug / re-slice without estimate), drop back
+    # to the project-level grams the archive columns already provide rather
+    # than ship a project-level headline next to an all-zero per-plate
+    # breakdown.
+    if plate_grams > 0:
+        archive_data["actual_filament_grams"] = round(plate_grams * scale, 1)
+        archive_data["filament_slots"] = [
+            {
+                "slot_id": s.get("slot_id"),
+                "used_g": round((s.get("used_g") or 0) * scale, 1),
+                "type": s.get("type", ""),
+                "color": s.get("color", ""),
+            }
+            for s in plate_slots
+        ]
+
+    return archive_data
+
+
 def _extract_filament_data_from_mqtt(data: dict, ams_mapping: list[int] | None = None) -> dict[str, str]:
     """Best-effort filament metadata from the MQTT print-start snapshot.
 
@@ -843,6 +1016,53 @@ _last_status_broadcast: dict[int, str] = {}
 _nozzle_count_updated: set[int] = set()
 
 
+async def _maybe_notify_printer_offline(printer_id: int) -> None:
+    """Wait the debounce window then fire `on_printer_offline` if the printer
+    is still offline.
+
+    Scheduled by `on_printer_status_change` on the connected → disconnected
+    edge (#1752). Cancelled by the same handler if the printer reconnects
+    before the window elapses, so a single MQTT blip + recovery doesn't
+    notify. Both the staleness-detector path (`bambu_mqtt.py::check_staleness`)
+    and the smart-plug power-off path (`printer_manager.mark_printer_offline`)
+    route through the same status-change callback, so this covers both.
+    """
+    logger = logging.getLogger(__name__)
+    try:
+        await asyncio.sleep(_PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS)
+        still_offline = not printer_manager.is_connected(printer_id)
+        logger.info(
+            "[#1752] Printer %s offline debounce elapsed: still_offline=%s",
+            printer_id,
+            still_offline,
+        )
+        if not still_offline:
+            return
+        async with async_session() as db:
+            from backend.app.models.printer import Printer
+
+            result = await db.execute(select(Printer).where(Printer.id == printer_id))
+            printer = result.scalar_one_or_none()
+            if not printer:
+                logger.warning(
+                    "[#1752] Printer %s missing from DB at offline-notify time; skipping",
+                    printer_id,
+                )
+                return
+            logger.info(
+                "[#1752] Dispatching on_printer_offline for printer %s (%s)",
+                printer_id,
+                printer.name,
+            )
+            await notification_service.on_printer_offline(printer_id, printer.name, db)
+    except asyncio.CancelledError:
+        raise
+    except Exception as e:
+        logger.warning("Printer offline notification failed for printer %s: %s", printer_id, e)
+    finally:
+        _printer_offline_notify_tasks.pop(printer_id, None)
+
+
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
     # Connected-edge reconciliation (#1542 follow-up). When the printer
@@ -883,6 +1103,34 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         # Re-arm so the next reconnect triggers reconciliation again.
         _printer_reconciled_since_connect[printer_id] = False
 
+    # Offline-notification edge (#1752): schedule `on_printer_offline` on
+    # connected → disconnected. The "back online" channel is already covered
+    # by the print-failure notification (firmware reports gcode_state=FAILED
+    # on reconnect of an interrupted print), so we don't add a symmetric
+    # online event here.
+    prev_connected = _printer_last_connected.get(printer_id)
+    _printer_last_connected[printer_id] = state.connected
+    if prev_connected is True and not state.connected:
+        existing = _printer_offline_notify_tasks.get(printer_id)
+        if existing is None or existing.done():
+            logging.getLogger(__name__).info(
+                "[#1752] Printer %s connected→disconnected edge; scheduling offline notification in %.0fs",
+                printer_id,
+                _PRINTER_OFFLINE_NOTIFY_DEBOUNCE_SECONDS,
+            )
+            _printer_offline_notify_tasks[printer_id] = asyncio.create_task(
+                _maybe_notify_printer_offline(printer_id),
+                name=f"printer-offline-notify-{printer_id}",
+            )
+    elif state.connected:
+        pending = _printer_offline_notify_tasks.pop(printer_id, None)
+        if pending is not None and not pending.done():
+            logging.getLogger(__name__).info(
+                "[#1752] Printer %s reconnected before debounce; cancelling pending offline notification",
+                printer_id,
+            )
+            pending.cancel()
+
     # Only broadcast if something meaningful changed (reduce WebSocket spam)
     # Include rounded temperatures to detect meaningful temp changes (within 1 degree)
     temps = state.temperatures or {}
@@ -931,7 +1179,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         f"{state.stg_cur}:{bed_target}:{nozzle_target}:"
         f"{state.cooling_fan_speed}:{state.big_fan1_speed}:{state.big_fan2_speed}:"
         f"{state.chamber_light}:{state.active_extruder}:{state.tray_now}:{vt_tray_key}:"
-        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}"
+        f"{ams_dry_key}:{ams_tray_key}:{state.door_open}:{state.ams_filament_backup}"
     )
 
     # MQTT relay - publish status (before dedup check - always publish to MQTT)
@@ -1108,7 +1356,12 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
 
     await ws_manager.send_printer_status(
         printer_id,
-        printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+        printer_state_to_dict(
+            state,
+            printer_id,
+            printer_manager.get_model(printer_id),
+            printer_manager.get_drying_targets(printer_id),
+        ),
     )
 
 
@@ -1143,7 +1396,12 @@ async def on_ams_change(printer_id: int, ams_data: list):
             logger.info("[Printer %s] Broadcasting AMS change via WebSocket", printer_id)
             await ws_manager.send_printer_status(
                 printer_id,
-                printer_state_to_dict(state, printer_id, printer_manager.get_model(printer_id)),
+                printer_state_to_dict(
+                    state,
+                    printer_id,
+                    printer_manager.get_model(printer_id),
+                    printer_manager.get_drying_targets(printer_id),
+                ),
             )
     except Exception as e:
         logger.warning("Failed to broadcast AMS change for printer %s: %s", printer_id, e)
@@ -1352,6 +1610,8 @@ async def on_ams_change(printer_id: int, ams_data: list):
             )
 
             _spoolman_on = await get_setting(db, "spoolman_enabled")
+            _auto_add_raw = await get_setting(db, "auto_add_unknown_rfid")
+            _auto_add_unknown = _auto_add_raw is None or _auto_add_raw.lower() == "true"
             if not _spoolman_on or _spoolman_on.lower() != "true":
                 for ams_unit in ams_data:
                     if not isinstance(ams_unit, dict):
@@ -1365,6 +1625,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
                         tray_uuid = tray.get("tray_uuid", "")
                         tray_info_idx = tray.get("tray_info_idx", "")
                         if not tray.get("tray_type"):
+                            # Slot reported empty — drop any cached unknown-tag
+                            # broadcast so reinserting the same spool re-prompts.
+                            _clear_unknown_tag_dedup(printer_id, ams_id, tray_id)
                             continue  # Empty slot
                         # Check if assignment already exists for this slot
                         existing = await db.execute(
@@ -1504,8 +1767,27 @@ async def on_ams_change(printer_id: int, ams_data: list):
                                 spool = await find_matching_untagged_spool(db, tray)
                                 if spool:
                                     await link_tag_to_inventory_spool(db, spool, tray)
-                                else:
+                                elif _auto_add_unknown:
                                     spool = await create_spool_from_tray(db, tray)
+                                else:
+                                    # Auto-add disabled: surface the slot so the
+                                    # user can add it manually via the UI.
+                                    await _broadcast_unknown_tag(
+                                        printer_id=printer_id,
+                                        ams_id=ams_id,
+                                        tray_id=tray_id,
+                                        tag_uid=tag_uid,
+                                        tray_uuid=tray_uuid,
+                                        tray_type=tray.get("tray_type"),
+                                        tray_color=tray.get("tray_color"),
+                                        tray_sub_brands=tray.get("tray_sub_brands"),
+                                        tray_count=len(ams_unit.get("tray", [])),
+                                    )
+                                    continue
+                            # Slot matched (existing tag, untagged inventory
+                            # match, or freshly auto-created spool) — drop any
+                            # stale dedup so a future tag swap re-prompts.
+                            _clear_unknown_tag_dedup(printer_id, ams_id, tray_id)
                             await auto_assign_spool(
                                 printer_id,
                                 ams_id,
@@ -1534,28 +1816,20 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             )
                         elif is_valid_tag(tag_uid, tray_uuid):
                             # Non-BL spool with some tag — let user choose
-                            await ws_manager.broadcast(
-                                {
-                                    "type": "unknown_tag",
-                                    "printer_id": printer_id,
-                                    "ams_id": ams_id,
-                                    "tray_id": tray_id,
-                                    "tag_uid": tag_uid,
-                                    "tray_uuid": tray_uuid,
-                                }
-                            )
-                        else:
-                            # No tag at all — let user choose from inventory
-                            await ws_manager.broadcast(
-                                {
-                                    "type": "unknown_tag",
-                                    "printer_id": printer_id,
-                                    "ams_id": ams_id,
-                                    "tray_id": tray_id,
-                                    "tag_uid": "",
-                                    "tray_uuid": "",
-                                }
+                            await _broadcast_unknown_tag(
+                                printer_id=printer_id,
+                                ams_id=ams_id,
+                                tray_id=tray_id,
+                                tag_uid=tag_uid,
+                                tray_uuid=tray_uuid,
+                                tray_type=tray.get("tray_type"),
+                                tray_color=tray.get("tray_color"),
+                                tray_sub_brands=tray.get("tray_sub_brands"),
+                                tray_count=len(ams_unit.get("tray", [])),
                             )
+                        # No-tag slots (generic non-RFID filament) are left alone:
+                        # nothing to identify, prompting "+ Add" would just create
+                        # ghost spools with empty tags on every confirm.
     except Exception as e:
         logger.warning("RFID spool auto-assign failed: %s", e, exc_info=True)
 
@@ -1574,6 +1848,9 @@ async def on_ams_change(printer_id: int, ams_data: list):
             if sync_mode and sync_mode != "auto":
                 return  # Only sync on auto mode
 
+            _auto_add_raw_sm = await get_setting(db, "auto_add_unknown_rfid")
+            auto_add_unknown_rfid = _auto_add_raw_sm is None or _auto_add_raw_sm.lower() == "true"
+
             # `spoolman_disable_weight_sync` is deprecated (#1119) — weight is now
             # always owned by per-print tracking, never by AMS auto-sync. The
             # setting is still read by the settings UI for backwards compat but
@@ -1666,7 +1943,10 @@ async def on_ams_change(printer_id: int, ams_data: list):
                     tray = client.parse_ams_tray(ams_id, tray_data)
                     if not tray:
                         # Empty tray slot — record for local assignment cleanup
+                        # and drop any cached unknown-tag broadcast so a
+                        # reinserted spool re-prompts.
                         empty_slots.append((ams_id, tray_id_raw))
+                        _clear_unknown_tag_dedup(printer_id, ams_id, tray_id_raw)
                         continue
 
                     spool_tag = (
@@ -1690,7 +1970,24 @@ async def on_ams_change(printer_id: int, ams_data: list):
                             cached_spools=cached_spools,
                             inventory_remaining=inv_remaining,
                             spoolman_spool_id_hint=hint,
+                            auto_add_unknown_rfid=auto_add_unknown_rfid,
                         )
+                        if result is None and spool_tag and not auto_add_unknown_rfid:
+                            # Spoolman skipped auto-create per user setting — surface
+                            # the slot so the UI can offer "+ Add to inventory".
+                            await _broadcast_unknown_tag(
+                                printer_id=printer_id,
+                                ams_id=ams_id,
+                                tray_id=tray.tray_id,
+                                tag_uid=tray.tag_uid or "",
+                                tray_uuid=tray.tray_uuid or "",
+                                tray_type=tray.tray_type,
+                                tray_color=tray.tray_color,
+                                tray_sub_brands=tray.tray_sub_brands,
+                                tray_count=len(trays),
+                            )
+                        elif result:
+                            _clear_unknown_tag_dedup(printer_id, ams_id, tray.tray_id)
                         if result:
                             synced += 1
                             if result.get("id"):
@@ -2272,7 +2569,16 @@ async def on_print_start(printer_id: int, data: dict):
                     _dispatched = getattr(_client, "last_dispatch_subtask_id", None) if _client else None
                     if _dispatched:
                         effective_subtask_id = str(_dispatched).strip() or None
-                if effective_subtask_id and not archive.subtask_id:
+                # Update on first-set OR on reprint (the queue dispatcher mints
+                # a fresh subtask_id per dispatch in bambu_mqtt:3647). Skipping
+                # the rewrite for reprints leaves the archive holding the FIRST
+                # run's id; if MQTT then reconnects mid-print, the reconciler
+                # (#1542) compares the stale stored id against the printer's
+                # live id, sees a mismatch, and synthesises a bogus PRINT
+                # COMPLETE — exactly the false-positive "Print Stopped" reported
+                # in #1807. Inequality check preserves the noop-on-stable-push
+                # behaviour the earlier `not archive.subtask_id` guard provided.
+                if effective_subtask_id and archive.subtask_id != effective_subtask_id:
                     archive.subtask_id = effective_subtask_id
                 # #1403 follow-up: VP-queue archives are created with
                 # printer_id=None at queue-add time (we don't know which
@@ -2480,8 +2786,11 @@ async def on_print_start(printer_id: int, data: dict):
                 )
                 # Track this as the active print
                 _active_prints[(printer_id, existing_archive.filename)] = existing_archive.id
-                # Attach subtask_id retroactively so future restarts can resume
-                if subtask_id and not existing_archive.subtask_id:
+                # Attach subtask_id retroactively so future restarts can resume.
+                # Compare for inequality (not "is empty") to also pick up reprint
+                # dispatches that mint a fresh id — see #1807 for the bogus
+                # "Print Stopped" the strict-empty guard caused on reconnect.
+                if subtask_id and existing_archive.subtask_id != subtask_id:
                     existing_archive.subtask_id = subtask_id
                     await db.commit()
                 # Also set up energy tracking if not already tracked (#941: persisted column)
@@ -3565,6 +3874,14 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
         )
         return
 
+    # #1790: register the producer-done event BEFORE the first await so the
+    # consumer in `_background_finish_photo` — which is dispatched back-to-back
+    # with us on the FINISH-state fallback path — sees it as soon as it polls.
+    # The `finally` below guarantees `set()` runs on every exit, including
+    # early returns and exceptions, so the consumer's bounded wait can't hang.
+    producer_done = asyncio.Event()
+    _stage22_finish_in_flight[printer_id] = producer_done
+
     try:
         async with async_session() as db:
             from backend.app.api.routes.settings import get_setting
@@ -3637,6 +3954,11 @@ async def on_finish_photo_moment(printer_id: int, data: dict):
             printer_id,
             e,
         )
+    finally:
+        # #1790: always unblock the consumer's bounded wait — whether we stored
+        # a frame, gave up, or hit an exception. Local ref means cleanup of the
+        # dict entry by the consumer doesn't affect signalling.
+        producer_done.set()
 
 
 async def on_print_complete(printer_id: int, data: dict):
@@ -4088,8 +4410,12 @@ async def on_print_complete(printer_id: int, data: dict):
     # Always drain the plate_id register on completion — the session already
     # consumed it at print-start injection; leaving it would leak into the next
     # print on the same archive_id (rare but possible with reprints) (#1697).
+    # Capture the popped value so the completion notification can scope the
+    # archive-level (summed-across-plates per #1593) filament + time totals
+    # down to the single plate that was actually printed (#1785).
+    notify_plate_id: int | None = None
     if archive_id:
-        _print_plate_ids.pop(archive_id, None)
+        notify_plate_id = _print_plate_ids.pop(archive_id, None)
 
     # Internal inventory: track AMS remain% deltas (skip if Spoolman handles usage)
     try:
@@ -4504,6 +4830,22 @@ async def on_print_complete(printer_id: int, data: dict):
                             # has the better framing instead of the post-bed-drop angle
                             # the live-camera fallback below would give.
                             if not photo_filename:
+                                # #1790: on the FINISH-state fallback path the producer
+                                # task is dispatched back-to-back with this consumer, so
+                                # a bare pop would race past with an empty result and
+                                # the RTSP fallback below would collide with the
+                                # producer's still-in-flight grab (single-client RTSP
+                                # on Bambu printers). Wait for the producer to finish
+                                # or give up before touching the cache.
+                                in_flight = _stage22_finish_in_flight.pop(printer_id, None)
+                                if in_flight is not None:
+                                    try:
+                                        await asyncio.wait_for(in_flight.wait(), timeout=20.0)
+                                    except asyncio.TimeoutError:
+                                        logger.warning(
+                                            "[PHOTO-BG] timed out waiting for stage-22 producer for printer %s — proceeding to fallback",
+                                            printer_id,
+                                        )
                                 cached_frame = _stage22_finish_frames.pop(printer_id, None)
                                 if cached_frame:
                                     photos_dir = archive_dir / "photos"
@@ -4642,7 +4984,7 @@ async def on_print_complete(printer_id: int, data: dict):
                         # Scale filament usage for partial prints
                         if print_status != "completed" and archive.filament_used_grams:
                             progress = data.get("progress") or 0
-                            scale = max(0.0, min(progress / 100.0, 1.0))
+                            scale = _partial_progress_scale(progress)
                             archive_data["actual_filament_grams"] = round(archive.filament_used_grams * scale, 1)
                             archive_data["progress"] = progress
 
@@ -4650,10 +4992,22 @@ async def on_print_complete(printer_id: int, data: dict):
                         if archive.extra_data and archive.extra_data.get("filament_slots"):
                             slots = archive.extra_data["filament_slots"]
                             if print_status != "completed":
-                                scale = max(0.0, min((data.get("progress") or 0) / 100.0, 1.0))
+                                scale = _partial_progress_scale(data.get("progress"))
                                 slots = [{**s, "used_g": round(s["used_g"] * scale, 1)} for s in slots]
                             archive_data["filament_slots"] = slots
 
+                        # Scope project-summed totals down to the plate that was
+                        # actually printed — see _scope_notification_archive_data_to_plate
+                        # for the why (#1785).
+                        archive_data = _scope_notification_archive_data_to_plate(
+                            archive_data,
+                            archive.file_path,
+                            notify_plate_id,
+                            print_status,
+                            data.get("progress"),
+                            app_settings.base_dir,
+                        )
+
                         # Enrich filament_grams from usage_results when archive has no 3MF data
                         if not archive_data.get("actual_filament_grams") and usage_results:
                             total_from_usage = sum(r.get("weight_used", 0) for r in usage_results)
@@ -4925,6 +5279,29 @@ async def record_ams_history():
                     except (ValueError, TypeError):
                         pass  # Keep default threshold if stored value is invalid
 
+                # Per-filament humidity threshold overrides (#1605) — resolved
+                # per-AMS below from the loaded tray types. Reuses the same
+                # resolver as the auto-drying scheduler so behavior stays in
+                # lockstep across both consumers.
+                from backend.app.services.print_scheduler import PrintScheduler
+
+                per_type_humidity_thresholds: dict[str, int] = {}
+                result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_thresholds"))
+                setting = result.scalar_one_or_none()
+                if setting and setting.value:
+                    try:
+                        raw = json.loads(setting.value)
+                        if isinstance(raw, dict):
+                            for k, v in raw.items():
+                                try:
+                                    per_type_humidity_thresholds[str(k).upper() if k != "default" else "default"] = int(
+                                        v
+                                    )
+                                except (TypeError, ValueError):
+                                    continue
+                    except (ValueError, TypeError):
+                        pass  # Invalid JSON → no overrides, fall through to global threshold
+
                 recorded_count = 0
                 for printer in printers:
                     # Get current state from printer manager
@@ -4996,8 +5373,18 @@ async def record_ams_history():
                         if not _ams_has_filament(ams_data):
                             continue
 
+                        # Resolve per-filament humidity threshold for this AMS
+                        # unit (#1605). Falls back to the global ams_humidity_fair
+                        # when no per-type overrides are configured.
+                        trays = ams_data.get("tray", []) or []
+                        effective_humidity_threshold = float(
+                            PrintScheduler.resolve_humidity_threshold(
+                                trays, per_type_humidity_thresholds, int(humidity_threshold)
+                            )
+                        )
+
                         # Check humidity alarm (only if above threshold)
-                        if humidity is not None and humidity > humidity_threshold:
+                        if humidity is not None and humidity > effective_humidity_threshold:
                             cooldown_key = f"{printer.id}:{ams_id}:humidity"
                             last_alarm = _ams_alarm_cooldown.get(cooldown_key)
                             now = datetime.now(timezone.utc)
@@ -5007,17 +5394,27 @@ async def record_ams_history():
                             ):
                                 _ams_alarm_cooldown[cooldown_key] = now
                                 logger.info(
-                                    f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%"
+                                    f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {effective_humidity_threshold}%"
                                 )
                                 try:
                                     # Call different notification method based on AMS type
                                     if is_ams_ht:
                                         await notification_service.on_ams_ht_humidity_high(
-                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                            printer.id,
+                                            printer.name,
+                                            ams_label,
+                                            humidity,
+                                            effective_humidity_threshold,
+                                            db,
                                         )
                                     else:
                                         await notification_service.on_ams_humidity_high(
-                                            printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                            printer.id,
+                                            printer.name,
+                                            ams_label,
+                                            humidity,
+                                            effective_humidity_threshold,
+                                            db,
                                         )
                                 except Exception as e:
                                     logger.warning("Failed to send humidity alarm: %s", e)
@@ -5099,6 +5496,133 @@ def stop_ams_history_recording():
         logging.getLogger(__name__).info("AMS history recording stopped")
 
 
+# Printer sensor history recording (nozzle / bed / chamber)
+_printer_sensor_history_task: asyncio.Task | None = None
+PRINTER_SENSOR_HISTORY_INTERVAL = 60  # Record every minute — heaters move faster than AMS humidity
+PRINTER_SENSOR_HISTORY_RETENTION_DAYS = 30
+_printer_sensor_cleanup_counter = 0
+# Sensor kinds tracked in state.temperatures — these are the normalised keys the
+# MQTT parser writes, so we don't need to handle per-model field aliases here
+# (nozzle_temper / left_nozzle_temper / right_nozzle_temper / chamber_temper
+# are all collapsed by services/bambu_mqtt.py before they reach this loop).
+_SENSOR_KINDS = ("nozzle", "nozzle_2", "bed", "chamber")
+_SENSOR_TARGET_KEYS = {
+    "nozzle": "nozzle_target",
+    "nozzle_2": "nozzle_2_target",
+    "bed": "bed_target",
+    "chamber": "chamber_target",
+}
+
+
+async def record_printer_sensor_history():
+    """Background task to record nozzle / bed / chamber readings.
+
+    Pulls from `state.temperatures` (already normalised across all printer
+    models by the MQTT parser) rather than re-parsing raw_data, so we get
+    free coverage of dual-nozzle H2D, sensor-only X1C chamber, etc.
+    """
+    logger = logging.getLogger(__name__)
+
+    await asyncio.sleep(10)
+
+    while True:
+        try:
+            from backend.app.models.printer import Printer
+            from backend.app.models.printer_sensor_history import PrinterSensorHistory
+            from backend.app.models.settings import Settings
+
+            async with async_session() as db:
+                result = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
+                printers = result.scalars().all()
+
+                recorded_count = 0
+                for printer in printers:
+                    state = printer_manager.get_status(printer.id)
+                    if not state or not state.connected:
+                        continue
+
+                    temps = getattr(state, "temperatures", None) or {}
+                    if not isinstance(temps, dict):
+                        continue
+
+                    for kind in _SENSOR_KINDS:
+                        if kind not in temps:
+                            continue
+                        try:
+                            value = float(temps[kind])
+                        except (ValueError, TypeError):
+                            continue
+
+                        target_raw = temps.get(_SENSOR_TARGET_KEYS[kind])
+                        target_val: float | None = None
+                        if target_raw is not None:
+                            try:
+                                target_val = float(target_raw)
+                            except (ValueError, TypeError):
+                                target_val = None
+
+                        db.add(
+                            PrinterSensorHistory(
+                                printer_id=printer.id,
+                                sensor_kind=kind,
+                                value=value,
+                                target=target_val,
+                            )
+                        )
+                        recorded_count += 1
+
+                await db.commit()
+                if recorded_count > 0:
+                    logger.debug("Recorded %s printer sensor history entries", recorded_count)
+
+                # Periodic cleanup — once every ~24h at this interval.
+                global _printer_sensor_cleanup_counter
+                _printer_sensor_cleanup_counter += 1
+                cleanup_every = max(1, (24 * 60 * 60) // PRINTER_SENSOR_HISTORY_INTERVAL)
+                if _printer_sensor_cleanup_counter >= cleanup_every:
+                    _printer_sensor_cleanup_counter = 0
+                    result = await db.execute(
+                        select(Settings).where(Settings.key == "printer_sensor_history_retention_days")
+                    )
+                    setting = result.scalar_one_or_none()
+                    retention_days = int(setting.value) if setting else PRINTER_SENSOR_HISTORY_RETENTION_DAYS
+
+                    cutoff = datetime.utcnow() - timedelta(days=retention_days)
+                    cleanup = await db.execute(
+                        delete(PrinterSensorHistory).where(PrinterSensorHistory.recorded_at < cutoff)
+                    )
+                    await db.commit()
+                    if cleanup.rowcount > 0:
+                        logger.info(
+                            "Cleaned up %s old printer sensor history entries (older than %s days)",
+                            cleanup.rowcount,
+                            retention_days,
+                        )
+
+            await asyncio.sleep(PRINTER_SENSOR_HISTORY_INTERVAL)
+
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logger.warning("Printer sensor history recording failed: %s", e)
+            await asyncio.sleep(60)
+
+
+def start_printer_sensor_history_recording():
+    global _printer_sensor_history_task
+    if _printer_sensor_history_task is None:
+        _printer_sensor_history_task = asyncio.create_task(record_printer_sensor_history())
+        logging.getLogger(__name__).info("Printer sensor history recording started")
+
+
+def stop_printer_sensor_history_recording():
+    global _printer_sensor_history_task
+    if _printer_sensor_history_task:
+        _printer_sensor_history_task.cancel()
+        _printer_sensor_history_task = None
+        logging.getLogger(__name__).info("Printer sensor history recording stopped")
+
+
 # Printer runtime tracking
 _runtime_tracking_task: asyncio.Task | None = None
 RUNTIME_TRACKING_INTERVAL = 30  # Update every 30 seconds
@@ -5671,9 +6195,6 @@ async def lifespan(app: FastAPI):
     # Start the print scheduler
     spawn_background_task(print_scheduler.run(), name="print-scheduler")
 
-    # Start background dispatch worker for send/start operations
-    await background_dispatch.start()
-
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
 
@@ -5699,6 +6220,9 @@ async def lifespan(app: FastAPI):
     # Start AMS history recording
     start_ams_history_recording()
 
+    # Start printer sensor (nozzle / bed / chamber) history recording
+    start_printer_sensor_history_recording()
+
     # Start printer runtime tracking
     start_runtime_tracking()
 
@@ -5736,7 +6260,6 @@ async def lifespan(app: FastAPI):
 
     # Shutdown
     print_scheduler.stop()
-    await background_dispatch.stop()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
@@ -5745,6 +6268,7 @@ async def lifespan(app: FastAPI):
     archive_purge_service.stop_scheduler()
     obico_detection_service.stop()
     stop_ams_history_recording()
+    stop_printer_sensor_history_recording()
     stop_runtime_tracking()
     stop_spoolbuddy_watchdog()
     stop_camera_cleanup()
@@ -5821,6 +6345,14 @@ PUBLIC_API_ROUTES = {
     "/api/v1/updates/version",
     # Metrics endpoint handles its own prometheus_token authentication
     "/api/v1/metrics",
+    # Appliance bootstrap (#1589 follow-up): the SPA's i18n setup polls
+    # this BEFORE a JWT is available to pick up the firstboot wizard's
+    # hostname / timezone / locale and the chrony NTP-gate state. The
+    # response contains user-set defaults and a public sync flag — no
+    # secrets. Without this entry the global auth middleware returns 401
+    # before the route handler runs, regardless of the route's own
+    # "no auth required" intent.
+    "/api/v1/system/appliance",
 }
 
 # Route prefixes that are public (for routes with dynamic segments)
@@ -6202,7 +6734,6 @@ app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 app.include_router(smart_plugs.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.router, prefix=app_settings.api_prefix)
 app.include_router(print_queue.router, prefix=app_settings.api_prefix)
-app.include_router(background_dispatch_routes.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(notifications.router, prefix=app_settings.api_prefix)
 app.include_router(notification_templates.router, prefix=app_settings.api_prefix)
@@ -6210,11 +6741,13 @@ app.include_router(user_notifications.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
+app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)
 app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.router, prefix=app_settings.api_prefix)
+app.include_router(library_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
@@ -6223,6 +6756,7 @@ app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
+app.include_router(printer_sensor_history.router, prefix=app_settings.api_prefix)
 app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(support.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)

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

@@ -10,6 +10,7 @@ from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.library import LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
+from backend.app.models.location import Location
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
@@ -19,10 +20,12 @@ from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.print_batch import PrintBatch
 from backend.app.models.printer import Printer
+from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug_energy_snapshot import SmartPlugEnergySnapshot
+from backend.app.models.sponsor_toast_state import SponsorToastState
 from backend.app.models.spool import Spool
 from backend.app.models.spool_assignment import SpoolAssignment
 from backend.app.models.spool_catalog import SpoolCatalogEntry
@@ -50,11 +53,13 @@ __all__ = [
     "Project",
     "APIKey",
     "AMSSensorHistory",
+    "PrinterSensorHistory",
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",
     "LibraryFolder",
     "LibraryFile",
+    "Location",
     "User",
     "Group",
     "user_groups",
@@ -71,6 +76,7 @@ __all__ = [
     "SpoolUsageHistory",
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
+    "SponsorToastState",
     "UserEmailPreference",
     "UserOTPCode",
     "UserTOTP",

+ 12 - 1
backend/app/models/active_print_spoolman.py

@@ -24,7 +24,11 @@ class ActivePrintSpoolman(Base):
     archive_id: Mapped[int] = mapped_column(ForeignKey("print_archives.id", ondelete="CASCADE"))
 
     # Per-filament usage from 3MF: [{"slot_id": 1, "used_g": 50.5, "type": "PLA"}, ...]
-    filament_usage: Mapped[list] = mapped_column(JSON)
+    # Nullable for the no-3MF case ("Untitled" prints where Bambu didn't keep a
+    # .gcode.3mf on the printer): the row still gets created so the completion
+    # path can use ``tray_remain_start`` for an AMS remain%-delta write,
+    # mirroring the internal-inventory Path 2 fallback in usage_tracker (#1820).
+    filament_usage: Mapped[list | None] = mapped_column(JSON, nullable=True)
 
     # AMS tray state at print start: {0: {"tray_uuid": "...", "tag_uid": "..."}, ...}
     ams_trays: Mapped[dict] = mapped_column(JSON)
@@ -40,3 +44,10 @@ class ActivePrintSpoolman(Base):
     # Filament properties (density, diameter per filament slot)
     # Format: {1: {"density": 1.24, "diameter": 1.75, "type": "PLA"}, ...}
     filament_properties: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+
+    # AMS tray remain% per slot at print start, captured so the completion
+    # path can compute a remain-delta when the 3MF didn't cover a slot (or
+    # there was no 3MF at all — #1820). Matches the internal-inventory
+    # ``tray_remain_start`` snapshot at usage_tracker.py:301.
+    # Format: {"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}, ...}
+    tray_remain_start: Mapped[dict | None] = mapped_column(JSON, nullable=True)

+ 4 - 1
backend/app/models/filament_sku_settings.py

@@ -13,13 +13,16 @@ class FilamentSkuSettings(Base):
     __table_args__ = (
         # sqlite_where ensures NULL columns participate in uniqueness (NULLS NOT DISTINCT).
         # On PostgreSQL the partial index is not needed — standard UNIQUE handles it.
-        UniqueConstraint("material", "subtype", "brand", name="uq_filament_sku"),
+        # color_name is part of the key so forecasts distinguish e.g. White vs Black
+        # PLA Matte (#forecast-color-grouping).
+        UniqueConstraint("material", "subtype", "brand", "color_name", name="uq_filament_sku"),
     )
 
     id: Mapped[int] = mapped_column(primary_key=True)
     material: Mapped[str] = mapped_column(String(50))
     subtype: Mapped[str | None] = mapped_column(String(50))
     brand: Mapped[str | None] = mapped_column(String(100))
+    color_name: Mapped[str | None] = mapped_column(String(100))
     lead_time_days: Mapped[int] = mapped_column(Integer, default=0)
     safety_margin_value: Mapped[int] = mapped_column(Integer, default=14)
     safety_margin_unit: Mapped[str] = mapped_column(String(10), default="days")

+ 48 - 0
backend/app/models/library.py

@@ -106,6 +106,13 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
+    # ``selectinload`` in list_files so each row in the listing carries its
+    # chip set without N+1 fetches.
+    tags: Mapped[list["LibraryTag"]] = relationship(
+        secondary="library_file_tags",
+        back_populates="files",
+    )
 
     @classmethod
     def active(cls) -> "Select[tuple[LibraryFile]]":
@@ -119,6 +126,47 @@ class LibraryFile(Base):
         return select(cls).where(cls.deleted_at.is_(None))
 
 
+class LibraryTag(Base):
+    """User-authored cross-cutting label for library files (#1268).
+
+    Folders express hierarchy; tags express orthogonal attributes ("toy",
+    "kid-safe", "petg-only"). Catalog is global (one tag set per install)
+    — the multi-user "private tags" case is not in v1 scope. ``name_key``
+    is ``LOWER(TRIM(name))`` so "Toys" / "toys" / "  TOYS  " all collide
+    on the UNIQUE index and the route returns 409 instead of silently
+    creating a duplicate.
+    """
+
+    __tablename__ = "library_tags"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(64), nullable=False)
+    name_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        secondary="library_file_tags",
+        back_populates="tags",
+    )
+
+
+class LibraryFileTag(Base):
+    """Association between library files and tags (#1268).
+
+    Composite PK so the same (file, tag) pair can't be inserted twice. Both
+    sides ON DELETE CASCADE: deleting a tag drops every association row,
+    deleting a file drops its tag links, and the catalog row survives so
+    other files keep their chip.
+    """
+
+    __tablename__ = "library_file_tags"
+
+    file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), primary_key=True)
+    tag_id: Mapped[int] = mapped_column(ForeignKey("library_tags.id", ondelete="CASCADE"), primary_key=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
 from backend.app.models.archive import PrintArchive  # noqa: E402, F811
 from backend.app.models.project import Project  # noqa: E402, F811
 from backend.app.models.user import User  # noqa: E402, F811

+ 27 - 0
backend/app/models/location.py

@@ -0,0 +1,27 @@
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from sqlalchemy import DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+if TYPE_CHECKING:
+    from backend.app.models.spool import Spool
+
+
+class Location(Base):
+    """Physical storage location for filament spools (shelf, drawer, drybox, etc.)."""
+
+    __tablename__ = "locations"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
+    # Case-insensitive uniqueness — LOWER(TRIM(name)); enforced via migration index.
+    name_key: Mapped[str] = mapped_column(String(255), nullable=False, unique=True, index=True)
+    # Reserved for Phase 3 RFID shelf tags — unused in Phase 1.
+    identifier: Mapped[str | None] = mapped_column(String(100))
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
+
+    spools: Mapped[list["Spool"]] = relationship(back_populates="location")

+ 1 - 0
backend/app/models/notification.py

@@ -70,6 +70,7 @@ class NotificationProvider(Base):
     # Event triggers - printer status
     on_printer_offline = Column(Boolean, default=False)
     on_printer_error = Column(Boolean, default=False)  # AMS issues, etc.
+    on_ai_failure_detection = Column(Boolean, default=False)  # Obico spaghetti / failure detection (#1794)
     on_filament_low = Column(Boolean, default=False)
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 

+ 15 - 5
backend/app/models/notification_template.py

@@ -73,6 +73,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Printer Error: {error_type}",
         "body_template": "{printer}\n{error_detail}",
     },
+    {
+        "event_type": "ai_failure_detection",
+        "name": "AI Failure Detection",
+        "title_template": "Possible Print Failure Detected",
+        "body_template": "{printer}: {task_name}\nConfidence: {confidence}\nAction taken: {action}",
+    },
     {
         "event_type": "plate_not_empty",
         "name": "Plate Not Empty",
@@ -189,28 +195,32 @@ DEFAULT_TEMPLATES = [
         "title_template": "Stock Break Risk: {material}",
         "body_template": "{material} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
     },
-    # User email notification templates (sent to the print job owner)
+    # User email notification templates (sent to the print job owner).
+    # Names include " Email" so they aren't confused with the provider-level
+    # `print_*` templates above, which share the same body shape but are
+    # broadcast to admin-configured providers (ntfy/pushover/telegram/discord/
+    # etc.) rather than mailed to a specific user.
     {
         "event_type": "user_print_start",
-        "name": "User Print Started",
+        "name": "User Print Started Email",
         "title_template": "Your Print Has Started",
         "body_template": "Hello {username},\n\nYour print job has started on {printer}.\n\nFile: {filename}\n\nYou will be notified when it completes.",
     },
     {
         "event_type": "user_print_complete",
-        "name": "User Print Completed",
+        "name": "User Print Completed Email",
         "title_template": "Your Print Is Complete",
         "body_template": "Hello {username},\n\nYour print job has completed on {printer}.\n\nFile: {filename}",
     },
     {
         "event_type": "user_print_failed",
-        "name": "User Print Failed",
+        "name": "User Print Failed Email",
         "title_template": "Your Print Has Failed",
         "body_template": "Hello {username},\n\nYour print job has failed on {printer}.\n\nFile: {filename}",
     },
     {
         "event_type": "user_print_stopped",
-        "name": "User Print Stopped",
+        "name": "User Print Stopped Email",
         "title_template": "Your Print Has Been Stopped",
         "body_template": "Hello {username},\n\nYour print job was stopped on {printer}.\n\nFile: {filename}",
     },

+ 7 - 0
backend/app/models/oidc_provider.py

@@ -121,6 +121,13 @@ class OIDCProvider(Base):
     # SHA-256 hex of icon_data, served as the ETag header so clients can
     # revalidate via If-None-Match and receive 304 Not Modified.
     icon_etag: Mapped[str | None] = mapped_column(String(64), nullable=True, default=None)
+    # When True, the LoginPage redirects unauthenticated visitors straight to
+    # this provider's authorize URL on mount (#1589). At most one provider can
+    # carry this flag at a time; setting it on a new provider clears it on the
+    # previous one. The frontend always falls back to the local form if the
+    # authorize-URL fetch fails or times out, and ``/login?fallback=local``
+    # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
+    is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
     @property
     def has_icon(self) -> bool:

+ 25 - 0
backend/app/models/print_queue.py

@@ -65,6 +65,22 @@ class PrintQueueItem(Base):
     # Auto-print G-code injection (#422)
     gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
 
+    # H2C dual-nozzle-rack slicer pick preservation (#1780). BambuStudio's
+    # project_file MQTT command for rack-swap-capable models (O1C2 today)
+    # carries per-filament physical nozzle position IDs in `nozzle_mapping`,
+    # forwarded verbatim through the queue and replayed by the dispatcher so
+    # the firmware honours the user's pick instead of falling back to
+    # "last matching nozzle type" auto-pick. Stored as opaque JSON string
+    # (list[int]); NULL on every other model. `nozzles_info` is a deprecated
+    # column from the original #1780 attempt — kept nullable so old rows still
+    # load; never written to or read from.
+    nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    nozzles_info: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+    # Printer-card direct uploads create transient library rows. When this is
+    # true, the scheduler deletes the source row/files after archiving a copy.
+    cleanup_library_after_dispatch: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Print options
     bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
     flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)
@@ -78,6 +94,15 @@ class PrintQueueItem(Base):
     # Status: pending, printing, completed, failed, skipped, cancelled
     status: Mapped[str] = mapped_column(String(20), default="pending")
 
+    # Cleared by the per-printer "Resume after failure" action (#1818) so the
+    # scheduler's `_check_previous_success` lookback skips this row. Without
+    # this, a single `failed` or `aborted` print poisoned every later
+    # `require_previous_success` item on the same printer forever — the
+    # lookback excluded `skipped` but had no way to dismiss the originating
+    # failure. The flag is per-item, not per-printer, so a fresh failure
+    # after a resume re-gates downstream items independently.
+    gate_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Set by the dispatch scheduler when the assigned spool can't satisfy
     # this print's per-slot filament weight (#1496). Display-only flag — the
     # actual deficit is recomputed live every time the user clicks ▶, so

+ 4 - 0
backend/app/models/printer.py

@@ -58,6 +58,9 @@ class Printer(Base):
     )
     kprofile_notes: Mapped[list["KProfileNote"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
     ams_history: Mapped[list["AMSSensorHistory"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
+    sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
 
 
 from backend.app.models.ams_history import AMSSensorHistory  # noqa: E402
@@ -65,4 +68,5 @@ from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.maintenance import PrinterMaintenance  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
+from backend.app.models.printer_sensor_history import PrinterSensorHistory  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402

+ 40 - 0
backend/app/models/printer_sensor_history.py

@@ -0,0 +1,40 @@
+from datetime import datetime
+
+from sqlalchemy import DateTime, Float, ForeignKey, Index, String, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class PrinterSensorHistory(Base):
+    """Historical heater readings (nozzle / nozzle_2 / bed / chamber).
+
+    Parallel to AMSSensorHistory, but per-(printer, sensor_kind) rather
+    than per-(printer, ams_id). Sensor counts vary by model (single vs
+    dual nozzle, presence of chamber heater), so a long-format row per
+    sensor reads cleanly and leaves room for future kinds (cpu, motor)
+    without another migration.
+    """
+
+    __tablename__ = "printer_sensor_history"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    sensor_kind: Mapped[str] = mapped_column(String(32))  # nozzle | nozzle_2 | bed | chamber
+    value: Mapped[float | None] = mapped_column(Float)  # current temperature, Celsius
+    target: Mapped[float | None] = mapped_column(Float)  # target temperature when set, Celsius
+    recorded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), index=True)
+
+    __table_args__ = (
+        Index(
+            "ix_printer_sensor_history_printer_kind_time",
+            "printer_id",
+            "sensor_kind",
+            "recorded_at",
+        ),
+    )
+
+    printer: Mapped["Printer"] = relationship(back_populates="sensor_history")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 1 - 0
backend/app/models/shopping_list.py

@@ -15,6 +15,7 @@ class ShoppingListItem(Base):
     material: Mapped[str] = mapped_column(String(50))
     subtype: Mapped[str | None] = mapped_column(String(50))
     brand: Mapped[str | None] = mapped_column(String(100))
+    color_name: Mapped[str | None] = mapped_column(String(100))
     quantity_spools: Mapped[int] = mapped_column(Integer, default=1)
     note: Mapped[str | None] = mapped_column(String(500))
     status: Mapped[str] = mapped_column(String(20), default="pending")  # pending | purchased | received

+ 39 - 0
backend/app/models/sponsor_toast_state.py

@@ -0,0 +1,39 @@
+"""Per-user (or install-default) state for the sponsor-prompt toast.
+
+A single row stores which sponsor-toast milestones have already fired for a
+given user, when the most recent toast was shown (for the 14-day cooldown),
+and the app version last seen so we can fire the "version-update" trigger
+exactly once per major bump.
+
+``user_id`` is nullable: in auth-disabled installs (no user concept), the
+service stores everything against a single NULL-keyed row.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class SponsorToastState(Base):
+    __tablename__ = "sponsor_toast_state"
+    __table_args__ = (UniqueConstraint("user_id", name="uq_sponsor_toast_state_user_id"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int | None] = mapped_column(
+        Integer,
+        ForeignKey("users.id", ondelete="CASCADE"),
+        nullable=True,
+        index=True,
+    )
+    last_shown_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    # JSON-serialised list[str] of milestone keys already fired (e.g. ["prints-100", "cost-100"]).
+    # Stored as Text for SQLite/Postgres uniformity; the service serialises with json.dumps.
+    milestones_seen: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
+    last_seen_version: Mapped[str | None] = mapped_column(String(50), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())

+ 4 - 1
backend/app/models/spool.py

@@ -1,6 +1,6 @@
 from datetime import datetime
 
-from sqlalchemy import Boolean, DateTime, Float, Integer, String, func
+from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 from backend.app.core.database import Base
@@ -61,6 +61,7 @@ class Spool(Base):
     cost_per_kg: Mapped[float | None] = mapped_column(Float)  # Cost per kilogram
 
     storage_location: Mapped[str | None] = mapped_column(String(255))  # User-editable storage location
+    location_id: Mapped[int | None] = mapped_column(ForeignKey("locations.id"), index=True)
 
     last_used: Mapped[datetime | None] = mapped_column(DateTime)  # Last time this spool was used in a print
     encode_time: Mapped[datetime | None] = mapped_column(DateTime)  # When spool was encoded/written to tag
@@ -74,7 +75,9 @@ class Spool(Base):
 
     k_profiles: Mapped[list["SpoolKProfile"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
     assignments: Mapped[list["SpoolAssignment"]] = relationship(back_populates="spool", cascade="all, delete-orphan")
+    location: Mapped["Location | None"] = relationship(back_populates="spools")
 
 
+from backend.app.models.location import Location  # noqa: E402
 from backend.app.models.spool_assignment import SpoolAssignment  # noqa: E402
 from backend.app.models.spool_k_profile import SpoolKProfile  # noqa: E402

+ 5 - 0
backend/app/models/virtual_printer.py

@@ -49,6 +49,11 @@ class VirtualPrinter(Base):
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
+    gcode_injection: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
+    # injection (#1516). Default off so existing gcode_snippets users don't
+    # silently start injecting; no-op when no snippets exist for the model.
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(

+ 3 - 25
backend/app/schemas/archive.py

@@ -27,7 +27,7 @@ class ArchiveDuplicate(BaseModel):
 
     id: int
     print_name: str | None
-    created_at: datetime
+    created_at: datetime | None
     match_type: str  # "exact" (hash match) or "similar" (name match)
 
 
@@ -94,7 +94,7 @@ class ArchiveResponse(BaseModel):
     energy_kwh: float | None = None
     energy_cost: float | None = None
 
-    created_at: datetime
+    created_at: datetime | None
 
     # User tracking (Issue #206)
     created_by_id: int | None = None
@@ -137,7 +137,7 @@ class ArchiveSlim(BaseModel):
     completed_at: datetime | None
     cost: float | None
     quantity: int = 1
-    created_at: datetime
+    created_at: datetime | None
 
     class Config:
         from_attributes = True
@@ -219,25 +219,3 @@ class ProjectPageUpdate(BaseModel):
     copyright: str | None = None
     profile_title: str | None = None
     profile_description: str | None = None
-
-
-class ReprintRequest(BaseModel):
-    """Request body for reprinting an archive."""
-
-    # Plate selection for multi-plate 3MF files
-    # If not specified, auto-detects from file (legacy behavior for single-plate files)
-    plate_id: int | None = None
-    plate_name: str | None = None
-
-    # AMS slot mapping: list of tray IDs for each filament slot in the 3MF
-    # Global tray ID = (ams_id * 4) + slot_id, external = 254
-    ams_mapping: list[int] | None = None
-
-    # Print options
-    bed_levelling: bool = True
-    flow_cali: bool = False
-    vibration_cali: bool = True
-    layer_inspect: bool = False
-    timelapse: bool = False
-    use_ams: bool = True  # Not exposed in UI, but needed for API
-    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)

+ 3 - 0
backend/app/schemas/auth.py

@@ -413,6 +413,7 @@ class OIDCProviderCreate(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589 — at most one provider may carry this
 
     @field_validator("issuer_url")
     @classmethod
@@ -469,6 +470,7 @@ class OIDCProviderUpdate(BaseModel):
     require_email_verified: bool | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool | None = None  # #1589
 
     @field_validator("scopes")
     @classmethod
@@ -515,6 +517,7 @@ class OIDCProviderResponse(BaseModel):
     require_email_verified: bool = True
     icon_url: str | None = None
     default_group_id: int | None = None
+    is_autologin: bool = False  # #1589
     # Set explicitly in the route handler from `icon_content_type is not None`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).

+ 85 - 26
backend/app/schemas/library.py

@@ -50,6 +50,12 @@ class FolderResponse(BaseModel):
     external_readonly: bool = False
     external_show_hidden: bool = False
     file_count: int = 0  # Computed field
+    # max(folder.updated_at, max(immediate-child file.updated_at)). Used by the
+    # File Manager folder tree's "sort by recent activity" mode (#1770) so that
+    # adding a file inside a folder bubbles it up — folder.updated_at alone only
+    # tracks rename/move events. Recursion across subfolders is intentionally
+    # left out to keep the route a single GROUP BY rather than a recursive CTE.
+    latest_activity_at: datetime | None = None
     created_at: datetime
     updated_at: datetime
 
@@ -57,6 +63,19 @@ class FolderResponse(BaseModel):
         from_attributes = True
 
 
+class FolderReadmeResponse(BaseModel):
+    """Markdown sidebar payload for a folder (#1268).
+
+    ``filename`` is the on-disk name (so the UI can show "README.md") and
+    ``content`` is the raw markdown — the FE renders it. ``truncated`` is
+    True when the source file was clipped at the size cap.
+    """
+
+    filename: str
+    content: str
+    truncated: bool
+
+
 class FolderTreeItem(BaseModel):
     """Schema for folder tree item (includes children)."""
 
@@ -71,6 +90,8 @@ class FolderTreeItem(BaseModel):
     external_path: str | None = None
     external_readonly: bool = False
     file_count: int = 0
+    # See FolderResponse.latest_activity_at — #1770 folder sort source.
+    latest_activity_at: datetime | None = None
     children: list["FolderTreeItem"] = []
 
     class Config:
@@ -158,6 +179,16 @@ class FileResponse(BaseModel):
         from_attributes = True
 
 
+class TagSummary(BaseModel):
+    """Compact tag projection — embedded in file listings (#1268)."""
+
+    id: int
+    name: str
+
+    class Config:
+        from_attributes = True
+
+
 class FileListResponse(BaseModel):
     """Schema for file list item (lighter than full response)."""
 
@@ -181,42 +212,70 @@ class FileListResponse(BaseModel):
     filament_used_grams: float | None = None
     sliced_for_model: str | None = None
 
+    # Tags assigned to this file (#1268). Empty list when the file has none —
+    # never null, so the FE can iterate without a guard.
+    tags: list[TagSummary] = []
+
     class Config:
         from_attributes = True
 
 
-class FileMoveRequest(BaseModel):
-    """Schema for moving files to a folder."""
+# ============ Tag Schemas (#1268) ============
+
+
+class TagResponse(BaseModel):
+    """Tag with the count of files currently using it."""
+
+    id: int
+    name: str
+    file_count: int
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
 
-    file_ids: list[int]
-    folder_id: int | None = None  # None = move to root
 
+class TagCreate(BaseModel):
+    """Create a new tag (catalog row)."""
 
-class FilePrintRequest(BaseModel):
-    """Schema for printing a file from the library.
+    name: str = Field(..., min_length=1, max_length=64)
 
-    Note: printer_id is passed as a query parameter, not in the body.
+
+class TagUpdate(BaseModel):
+    """Rename a tag. ``name`` is required — there's nothing else to update."""
+
+    name: str = Field(..., min_length=1, max_length=64)
+
+
+class TagBulkAssignRequest(BaseModel):
+    """Bulk tag assignment payload.
+
+    ``action='add'``      → append tags to every listed file (idempotent on dup).
+    ``action='remove'``   → strip the listed tags from every listed file.
+    ``action='replace'``  → REPLACE the tag set on every listed file with the
+                            exact set in ``tag_ids`` (omitting tag_ids clears
+                            them all).
     """
 
-    # Print options (same as archive reprint)
-    plate_id: int | None = None
-    plate_name: str | None = None
-    ams_mapping: list[int] | None = None
-    bed_levelling: bool = True
-    flow_cali: bool = False
-    vibration_cali: bool = True
-    layer_inspect: bool = False
-    timelapse: bool = False
-    use_ams: bool = True
-    nozzle_offset_cali: bool = True  # Dual-nozzle printers only — MQTT-gated (#1682)
-    # Project to associate the resulting archive with
-    project_id: int | None = None
-    # When true, delete the LibraryFile row + disk file after the archive has
-    # been created and the print has been dispatched. Used by the Printers-page
-    # Direct-Print flow (click / drag-drop a file onto a printer card) so the
-    # transient upload doesn't linger in File Manager. Cleanup is skipped on
-    # external library files.
-    cleanup_library_after_dispatch: bool = False
+    file_ids: list[int] = Field(..., min_length=1)
+    tag_ids: list[int] = Field(default_factory=list)
+    action: str = Field("add", pattern="^(add|remove|replace)$")
+
+
+class TagBulkAssignResponse(BaseModel):
+    """Result of a bulk-assign call."""
+
+    files_updated: int
+    associations_added: int
+    associations_removed: int
+
+
+class FileMoveRequest(BaseModel):
+    """Schema for moving files to a folder."""
+
+    file_ids: list[int]
+    folder_id: int | None = None  # None = move to root
 
 
 class FileUploadResponse(BaseModel):

+ 39 - 0
backend/app/schemas/location.py

@@ -0,0 +1,39 @@
+from datetime import datetime
+
+from pydantic import BaseModel, Field, field_validator
+
+from backend.app.services.location_service import normalize_location_name
+
+
+class LocationCreate(BaseModel):
+    name: str = Field(..., min_length=1, max_length=255)
+    identifier: str | None = Field(default=None, max_length=100)
+
+    @field_validator("name")
+    @classmethod
+    def validate_name(cls, v: str) -> str:
+        return normalize_location_name(v)
+
+
+class LocationUpdate(BaseModel):
+    name: str | None = Field(default=None, min_length=1, max_length=255)
+    identifier: str | None = Field(default=None, max_length=100)
+
+    @field_validator("name")
+    @classmethod
+    def validate_name(cls, v: str | None) -> str | None:
+        if v is None:
+            return None
+        return normalize_location_name(v)
+
+
+class LocationResponse(BaseModel):
+    id: int
+    name: str
+    identifier: str | None = None
+    spool_count: int = 0
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True

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

@@ -43,6 +43,10 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - printer status
     on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
     on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
+    on_ai_failure_detection: bool = Field(
+        default=False,
+        description="Notify when Obico AI detects a possible print failure (spaghetti)",
+    )
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
     on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
 
@@ -128,6 +132,7 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - printer status
     on_printer_offline: bool | None = None
     on_printer_error: bool | None = None
+    on_ai_failure_detection: bool | None = None
     on_filament_low: bool | None = None
     on_maintenance_due: bool | None = None
 

+ 64 - 1
backend/app/schemas/print_queue.py

@@ -1,7 +1,7 @@
 from datetime import datetime
 from typing import Annotated, Literal
 
-from pydantic import BaseModel, PlainSerializer
+from pydantic import BaseModel, PlainSerializer, model_validator
 
 
 # Custom serializer to ensure UTC datetimes have Z suffix
@@ -28,6 +28,8 @@ class PrintQueueItemCreate(BaseModel):
     require_previous_success: bool = False
     auto_off_after: bool = False  # Power off printer after print completes
     manual_start: bool = False  # Requires manual trigger to start (staged)
+    insert_at_top: bool = False  # Insert ahead of other pending items in the same queue scope
+    insert_position: int | None = None  # 1-indexed insertion position for priority queueing
     # Persistent "Print Anyway" acknowledgement (#1698-followup). When set,
     # PrintModal already showed the deficit warning and the user confirmed,
     # so the scheduler does not re-flag this item on the next tick.
@@ -52,8 +54,15 @@ class PrintQueueItemCreate(BaseModel):
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
     quantity: int = 1
+    # Existing batch to add this item into. When set, the item's batch_id is
+    # populated on insert so the queue UI groups it with its siblings. Used by
+    # the multi-plate auto-batch flow and by the "Group as batch" action.
+    batch_id: int | None = None
     # Project to associate the resulting archive with
     project_id: int | None = None
+    # Direct printer-card uploads are temporary library files. The scheduler
+    # deletes them after creating the durable archive copy.
+    cleanup_library_after_dispatch: bool = False
 
 
 class PrintQueueItemUpdate(BaseModel):
@@ -78,6 +87,10 @@ class PrintQueueItemUpdate(BaseModel):
     nozzle_offset_cali: bool | None = None
     # Auto-print G-code injection
     gcode_injection: bool | None = None
+    # H2C dual-nozzle-rack slicer pick (#1780). list[int] per-filament
+    # physical nozzle position IDs from BambuStudio's project_file MQTT
+    # body; sent back to the printer verbatim on dispatch.
+    nozzle_mapping: list[int] | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -158,6 +171,12 @@ class PrintQueueItemResponse(BaseModel):
 
     # Auto-print G-code injection
     gcode_injection: bool = False
+    cleanup_library_after_dispatch: bool = False
+
+    # H2C dual-nozzle-rack slicer pick (#1780). Surface for any future
+    # "edit print → choose nozzle" UI; null on every model except O1C2
+    # uploads from BambuStudio.
+    nozzle_mapping: list[int] | None = None
 
     class Config:
         from_attributes = True
@@ -171,6 +190,30 @@ class PrintQueueReorderItem(BaseModel):
 class PrintQueueReorder(BaseModel):
     items: list[PrintQueueReorderItem]
 
+    @model_validator(mode="after")
+    def _validate_positions_unique(self) -> "PrintQueueReorder":
+        """Reject reorder requests with duplicate positions in the payload
+        (#1625-followup).
+
+        The /reorder route is the drag-drop renumber path on the queue UI;
+        a well-behaved client sends a contiguous renumbering of a single
+        printer's pending queue. A buggy client that sends two items at
+        the same position would leave the queue in an inconsistent state
+        (scheduler's ORDER BY (printer_id, position) ties get broken by
+        physical row order). Fail closed at the schema boundary so the
+        bug is caught before any DB mutation.
+
+        Uniqueness is enforced WITHIN THE PAYLOAD only — cross-printer
+        reorders that intentionally share positions across different
+        printer queues are a non-goal of the drag-drop UI, so this is the
+        right scope.
+        """
+        positions = [it.position for it in self.items]
+        if len(positions) != len(set(positions)):
+            duplicates = sorted({p for p in positions if positions.count(p) > 1})
+            raise ValueError(f"Duplicate positions in reorder request: {duplicates}")
+        return self
+
 
 class PrintQueueBulkUpdate(BaseModel):
     """Bulk update multiple queue items with the same values."""
@@ -202,6 +245,26 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     message: str
 
 
+class PrintBatchCreate(BaseModel):
+    """Create a batch, either empty (multi-plate pre-batch flow) or by
+    assigning existing pending queue items into it (manual "Group as batch")."""
+
+    name: str
+    archive_id: int | None = None
+    library_file_id: int | None = None
+    # Existing pending queue items to assign to this batch. None / empty for
+    # the empty-batch flow (client passes the returned id on subsequent
+    # addToQueue calls).
+    item_ids: list[int] | None = None
+
+
+class PrintBatchUngroupResponse(BaseModel):
+    """Response after ungrouping a batch."""
+
+    ungrouped_count: int
+    message: str
+
+
 class PrintBatchResponse(BaseModel):
     """Response for a print batch with progress stats."""
 

+ 50 - 3
backend/app/schemas/printer.py

@@ -29,7 +29,6 @@ class PrinterBase(BaseModel):
         max_length=253,
         pattern=r"^(\d{1,3}(\.\d{1,3}){3}|[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*)$",
     )
-    access_code: str = Field(..., min_length=1, max_length=20)
     model: str | None = None
     location: str | None = None  # Group/location name
     auto_archive: bool = True
@@ -41,7 +40,10 @@ class PrinterBase(BaseModel):
 
 
 class PrinterCreate(PrinterBase):
-    pass
+    # access_code lives on the input shapes only — never on the default
+    # PrinterResponse. Direct exposure on PRINTERS_READ would let a Viewer
+    # connect to the printer's MQTT and bypass Bambuddy's RBAC.
+    access_code: str = Field(..., min_length=1, max_length=20)
 
 
 class PlateDetectionROI(BaseModel):
@@ -101,7 +103,6 @@ class PrinterResponse(PrinterBase):
             "name": printer.name,
             "serial_number": printer.serial_number,
             "ip_address": printer.ip_address,
-            "access_code": printer.access_code,
             "model": printer.model,
             "location": printer.location,
             "auto_archive": printer.auto_archive,
@@ -135,11 +136,32 @@ class PrinterResponse(PrinterBase):
         return cls(**data)
 
 
+class PrinterResponseWithSecret(PrinterResponse):
+    """PrinterResponse + access_code. Returned ONLY to callers with
+    PRINTERS_UPDATE (Admin / Operator JWTs, or single-trust auth-disabled mode).
+
+    Viewers and API keys never receive this shape — they get the bare
+    PrinterResponse without access_code, since holding the access_code lets
+    the caller talk to the printer's MQTT directly and bypass Bambuddy's RBAC.
+    """
+
+    access_code: str
+
+
 class HMSErrorResponse(BaseModel):
     code: str
     attr: int = 0  # Attribute value for constructing wiki URL
     module: int
     severity: int  # 1=fatal, 2=serious, 3=common, 4=info
+    actions: list[str] = []  # List of user-facing action keys (e.g. "CHECK_FILAMENT")
+    job_id: str | None = None  # Optional job ID for actions that require it (e.g. "CHECK_ASSISTANT")
+    # Canonical hex identifier the firmware uses to match HMS-related commands.
+    # 16 chars for `hms[]`-array faults (full 64-bit attr+code), 8 chars for
+    # `print_error` faults. The frontend echoes this back as
+    # HmsActionBody.print_error so we send the firmware-recognised key, not the
+    # truncated short_code that historically caused silent command rejection
+    # (#1830, H2D wrong-plate verification).
+    full_code: str = ""
 
 
 class AMSTray(BaseModel):
@@ -173,6 +195,8 @@ class AMSUnit(BaseModel):
     dry_status: int = 0  # 0=Off, 1=Checking, 2=Drying, 3=Cooling, 4=Stopping, 5=Error
     dry_sub_status: int = 0  # 0=Off, 1=Heating, 2=Dehumidify
     dry_sf_reason: list[int] = []  # Cannot-dry reasons from firmware (see CannotDryReason)
+    dry_target_temp: int | None = None  # Active-cycle target °C (Bambu doesn't echo this)
+    dry_filament: str | None = None  # Active-cycle filament name we sent
     module_type: str = ""  # "ams", "n3f", "n3s"
 
 
@@ -201,6 +225,20 @@ class AmsLabelBody(BaseModel):
     ams_serial: str = Field(default="", max_length=50)
 
 
+class HmsActionBody(BaseModel):
+    # Canonical hex identifier (HMSErrorResponse.full_code): 8 chars for
+    # `print_error`-sourced faults, 16 chars for `hms[]`-array faults whose
+    # full 64-bit code is the firmware's matching key. Length-bounded to
+    # those two valid shapes to keep stray input from reaching the dispatcher.
+    print_error: str = Field(..., min_length=8, max_length=16, pattern=r"^[0-9A-Fa-f]{8}([0-9A-Fa-f]{8})?$")
+    # One of the HMSAction enum values. Length-capped to keep stray input from
+    # reaching the dispatcher's `match` statement.
+    action: str = Field(..., min_length=1, max_length=64)
+    # The `subtask_id` snapshot from the HMSError that surfaced this dialog.
+    # Bambu echoes it back in HMS-aware commands. Optional for idle errors.
+    job_id: str | None = Field(default=None, max_length=64)
+
+
 class FilaSwitchResponse(BaseModel):
     """Filament Track Switch (FTS) state — accessory that mediates AMS-to-extruder routing.
 
@@ -311,11 +349,20 @@ class PrinterStatus(BaseModel):
     firmware_version: str | None = None
     # Developer LAN mode: True = enabled, False = disabled (MQTT encryption), None = unknown
     developer_mode: bool | None = None
+    # AMS Filament Backup ("auto-switch" to a second spool when one runs out).
+    # True = ON, False = OFF, None = unknown / unsupported (A1 family — protocol field
+    # not yet identified). UI treats None as "status unavailable", not as a hard disable.
+    ams_filament_backup: bool | None = None
     # Queue: printer is awaiting the user to acknowledge the build plate is cleared
     # after a finished/failed print. Persisted across restarts (#961).
     awaiting_plate_clear: bool = False
     # AMS drying support
     supports_drying: bool = False
+    # AMS "Print While Drying" — drying mid-print. Verified per Bambu wiki release notes;
+    # see _DRY_WHILE_PRINTING_MIN_FIRMWARE in printer_manager.py for the matrix.
+    supports_drying_while_printing: bool = False
+    # Active chamber heater (responds to M141). True only for H2C/H2D/H2DPro/H2S/X2D.
+    supports_chamber_heater: bool = False
     # Linked archive for the active print (resolved via subtask_id). Frontend uses
     # this to fetch plate metadata and show the plate name when the source 3MF is
     # multi-plate (#881 follow-up).

+ 127 - 0
backend/app/schemas/settings.py

@@ -39,6 +39,10 @@ class AppSettings(BaseModel):
         default=True,
         description="Report Partial Usage for Failed Prints. When a print fails or is cancelled, report the estimated filament used up to that point based on layer progress.",
     )
+    auto_add_unknown_rfid: bool = Field(
+        default=True,
+        description="Automatically add spools with unknown RFID tags to inventory. Disable if you pre-create inventory entries manually to avoid duplicates.",
+    )
     disable_filament_warnings: bool = Field(
         default=False,
         description="Disable insufficient filament warnings when printing or queueing prints",
@@ -72,6 +76,9 @@ class AppSettings(BaseModel):
         default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red"
     )
     ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
+    printer_sensor_history_retention_days: int = Field(
+        default=30, description="Number of days to keep printer heater history data (nozzle / bed / chamber)"
+    )
 
     # Queue auto-drying settings
     queue_drying_enabled: bool = Field(
@@ -85,10 +92,28 @@ class AppSettings(BaseModel):
         default=False,
         description="Automatically dry AMS filament on idle printers when humidity exceeds threshold, regardless of queue",
     )
+    print_drying_enabled: bool = Field(
+        default=False,
+        description=(
+            "Allow auto-drying to also fire on a printer that is currently printing, "
+            "when its model+firmware supports concurrent drying (H2D 01.03.00.00+, "
+            "H2C/H2S/P2S/H2D Pro 01.02.00.00+, X2D/A2L 01.01.00.00+, X1C 01.11.02.00+). "
+            "Drying temperature is automatically capped 5 degC below the idle preset "
+            "(floor 40 degC) to protect spools during print."
+        ),
+    )
     drying_presets: str = Field(
         default="",
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
     )
+    ams_humidity_thresholds: str = Field(
+        default="",
+        description=(
+            "JSON blob of per-filament-type humidity trigger thresholds for auto-drying and alarms. "
+            'Shape: {"default": int, "PLA": int, "ASA": int, ...}. '
+            "Empty = fall back to ams_humidity_fair for all types."
+        ),
+    )
 
     # Auto-print G-code injection (#422)
     gcode_snippets: str = Field(
@@ -240,6 +265,20 @@ class AppSettings(BaseModel):
         description="Low stock threshold percentage (%) for inventory filtering and display",
     )
 
+    # Session policy (#1706) — admin-set ceiling for user session lifetime.
+    # Default 24h preserves the M-2 audit reduction from 7 days. Max 720h
+    # (30 days) bounds blast radius if an admin chooses a long session.
+    session_max_hours: int = Field(
+        default=24,
+        ge=1,
+        le=720,
+        description=(
+            "Maximum session lifetime in hours for user logins (default 24, max 720). "
+            "Applies to new logins only; already-issued tokens keep their original expiry. "
+            "Longer sessions reduce automatic logout protection."
+        ),
+    )
+
     # User email notifications (requires Advanced Authentication)
     user_notifications_enabled: bool = Field(
         default=True,
@@ -279,6 +318,42 @@ class AppSettings(BaseModel):
         description="Shortest Job First — scheduler prioritizes shorter print jobs over longer ones",
     )
 
+    # User-configurable presets for the printer-card temperature / fan-speed
+    # popovers. Each is a JSON array of exactly 3 ints (the "Off" button is
+    # rendered separately and is not configurable). Empty string = use built-in
+    # defaults. Validators on AppSettingsUpdate enforce the shape on writes.
+    nozzle_temp_presets: str = Field(
+        default="",
+        description="JSON array of 3 nozzle-temperature preset values in C (0-320). Empty = use defaults [120, 220, 260]",
+    )
+    bed_temp_presets: str = Field(
+        default="",
+        description="JSON array of 3 bed-temperature preset values in C (0-140). Empty = use defaults [55, 75, 90]",
+    )
+    chamber_temp_presets: str = Field(
+        default="",
+        description="JSON array of 3 chamber-temperature preset values in C (0-60). Empty = use defaults [35, 45, 60]",
+    )
+    fan_speed_presets: str = Field(
+        default="",
+        description="JSON array of 3 fan-speed preset values in % (0-100). Empty = use defaults [50, 75, 100]",
+    )
+
+    # Local login (#1589) — when False, /auth/login rejects username+password
+    # credentials with HTTP 403 and the login page hides the credentials form,
+    # leaving only the OIDC SSO provider buttons. LDAP is governed by its own
+    # `ldap_enabled` toggle and is not affected. The env-var
+    # ``BAMBUDDY_LOCAL_LOGIN=true`` bypasses this gate at the route level so a
+    # server admin can recover an install whose SSO provider is unreachable
+    # without editing the DB.
+    local_login_enabled: bool = Field(
+        default=True,
+        description=(
+            "Allow username + password login on /auth/login. Disable when only SSO should be usable. "
+            "BAMBUDDY_LOCAL_LOGIN=true on the server overrides this to keep a recovery path open."
+        ),
+    )
+
     # LDAP authentication (#794)
     ldap_enabled: bool = Field(default=False, description="Enable LDAP authentication")
     ldap_server_url: str = Field(default="", description="LDAP server URL (e.g., ldap://ldap.example.com:389)")
@@ -357,11 +432,13 @@ class AppSettingsUpdate(BaseModel):
     spoolman_sync_mode: str | None = None
     spoolman_disable_weight_sync: bool | None = None
     spoolman_report_partial_usage: bool | None = None
+    auto_add_unknown_rfid: bool | None = None
     disable_filament_warnings: bool | None = None
     prefer_lowest_filament: bool | None = None
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    local_login_enabled: bool | None = None
     language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
@@ -370,10 +447,13 @@ class AppSettingsUpdate(BaseModel):
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
     ams_history_retention_days: int | None = None
+    printer_sensor_history_retention_days: int | None = None
     queue_drying_enabled: bool | None = None
     queue_drying_block: bool | None = None
     ambient_drying_enabled: bool | None = None
+    print_drying_enabled: bool | None = None
     drying_presets: str | None = None
+    ams_humidity_thresholds: str | None = None
     per_printer_mapping_expanded: bool | None = None
     date_format: str | None = None
     time_format: str | None = None
@@ -414,6 +494,7 @@ class AppSettingsUpdate(BaseModel):
     prometheus_enabled: bool | None = None
     prometheus_token: str | None = None
     low_stock_threshold: float | None = Field(default=None, ge=0.1, le=99.9)
+    session_max_hours: int | None = Field(default=None, ge=1, le=720)
     user_notifications_enabled: bool | None = None
     default_bed_levelling: bool | None = None
     default_flow_cali: bool | None = None
@@ -425,6 +506,10 @@ class AppSettingsUpdate(BaseModel):
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
+    nozzle_temp_presets: str | None = None
+    bed_temp_presets: str | None = None
+    chamber_temp_presets: str | None = None
+    fan_speed_presets: str | None = None
     gcode_snippets: str | None = None
     local_backup_enabled: bool | None = None
     local_backup_schedule: str | None = None
@@ -489,6 +574,43 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("obico_enabled_printers must be a JSON array of printer IDs (integers)")
         return v
 
+    @staticmethod
+    def _validate_preset_triple(v: str | None, field_name: str, lo: int, hi: int) -> str | None:
+        """Validate a JSON array of exactly 3 ints in [lo, hi]. Empty = defaults."""
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError(f"{field_name} must be valid JSON or empty")
+        if not isinstance(parsed, list) or len(parsed) != 3:
+            raise ValueError(f"{field_name} must be a JSON array of exactly 3 integers")
+        if not all(isinstance(item, int) and not isinstance(item, bool) for item in parsed):
+            raise ValueError(f"{field_name} entries must all be integers")
+        if not all(lo <= item <= hi for item in parsed):
+            raise ValueError(f"{field_name} entries must each be in [{lo}, {hi}]")
+        return v
+
+    @field_validator("nozzle_temp_presets")
+    @classmethod
+    def validate_nozzle_temp_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "nozzle_temp_presets", 0, 320)
+
+    @field_validator("bed_temp_presets")
+    @classmethod
+    def validate_bed_temp_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "bed_temp_presets", 0, 140)
+
+    @field_validator("chamber_temp_presets")
+    @classmethod
+    def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, 60)
+
+    @field_validator("fan_speed_presets")
+    @classmethod
+    def validate_fan_speed_presets(cls, v: str | None) -> str | None:
+        return cls._validate_preset_triple(v, "fan_speed_presets", 0, 100)
+
     @field_validator("obico_sensitivity")
     @classmethod
     def validate_obico_sensitivity(cls, v: str | None) -> str | None:
@@ -518,6 +640,11 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError("default_sidebar_order must be valid JSON or empty")
         if isinstance(parsed, dict):
             order = parsed.get("order")
+            hidden_system_item_ids = parsed.get("hiddenSystemItemIds", [])
+            if not isinstance(hidden_system_item_ids, list) or not all(
+                isinstance(item, str) for item in hidden_system_item_ids
+            ):
+                raise ValueError("sidebar hidden system item IDs must be an array of strings")
         elif isinstance(parsed, list):
             order = parsed
         else:

+ 23 - 0
backend/app/schemas/sponsor_prompt.py

@@ -0,0 +1,23 @@
+"""Pydantic schemas for the sponsor-prompt API."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pydantic import BaseModel, Field
+
+
+class SponsorPromptCheckResponse(BaseModel):
+    """Result of GET /sponsor-prompt/check."""
+
+    show: bool
+    milestone: str | None = None
+    family: str | None = None
+    threshold: int | None = None
+    payload: dict[str, Any] = Field(default_factory=dict)
+
+
+class SponsorPromptDismissRequest(BaseModel):
+    """Body of POST /sponsor-prompt/dismiss."""
+
+    milestone: str

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

@@ -125,6 +125,7 @@ class SpoolBase(BaseModel):
     # assignment). Column has lived on the ORM since the inventory rework
     # but was missing from this schema, so writes were silently dropped (#1291).
     storage_location: str | None = Field(default=None, max_length=255)
+    location_id: int | None = Field(default=None, gt=0)
 
 
 class SpoolCreate(SpoolBase):
@@ -174,6 +175,7 @@ class SpoolUpdate(BaseModel):
     category: str | None = Field(default=None, max_length=50)
     low_stock_threshold_pct: int | None = Field(default=None, ge=1, le=99)
     storage_location: str | None = Field(default=None, max_length=255)
+    location_id: int | None = Field(default=None, gt=0)
 
 
 class SpoolKProfileBase(BaseModel):

+ 0 - 1100
backend/app/services/background_dispatch.py

@@ -1,1100 +0,0 @@
-"""Background dispatch for print/reprint jobs.
-
-This service is separate from the app's print queue feature. It exists only to
-decouple "send/start print" operations (FTP upload + start command) from API
-request latency so the UI can continue immediately after dispatch.
-"""
-
-from __future__ import annotations
-
-import asyncio
-import logging
-import time
-import zipfile
-from collections import deque
-from dataclasses import dataclass, field
-from pathlib import Path
-from typing import Any, Literal
-
-from sqlalchemy import select
-
-from backend.app.core.config import settings
-from backend.app.core.database import async_session
-from backend.app.core.tasks import spawn_background_task
-from backend.app.core.websocket import ws_manager
-from backend.app.models.library import LibraryFile
-from backend.app.models.printer import Printer
-from backend.app.services.archive import ArchiveService
-from backend.app.services.bambu_ftp import (
-    cache_3mf_download,
-    delete_file_async,
-    get_ftp_retry_settings,
-    upload_file_async,
-    with_ftp_retry,
-)
-from backend.app.services.printer_manager import printer_manager
-from backend.app.utils.filename import derive_remote_filename
-
-logger = logging.getLogger(__name__)
-
-# Bambu firmware states that mean the project_file has actually been accepted
-# and the printer is now processing / running / paused mid-print. Used by the
-# direct-dispatch verifier (#1370): a transition into one of these states means
-# the print landed, anything else (e.g. FINISH -> IDLE after the user dismisses
-# a post-print prompt) is NOT a valid "command landed" signal even though the
-# state value did change. Mirrors the same constant in print_scheduler.py —
-# kept duplicated rather than imported to avoid coupling the two services and
-# to keep the value at the point of use.
-_ACTIVE_PRINT_STATES: frozenset[str] = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
-
-
-class DispatchJobCancelled(Exception):
-    """Raised when a dispatch job is cancelled by the user."""
-
-
-class DispatchEnqueueRejected(Exception):
-    """Raised when a dispatch job should not be accepted."""
-
-
-@dataclass(slots=True)
-class PrintDispatchJob:
-    id: int
-    kind: Literal["reprint_archive", "print_library_file"]
-    source_id: int
-    source_name: str
-    printer_id: int
-    printer_name: str
-    options: dict[str, Any] = field(default_factory=dict)
-    requested_by_user_id: int | None = None
-    requested_by_username: str | None = None
-    project_id: int | None = None
-    cleanup_library_after_dispatch: bool = False
-
-
-@dataclass(slots=True)
-class ActiveDispatchState:
-    job: PrintDispatchJob
-    message: str
-    upload_bytes: int | None = None
-    upload_total_bytes: int | None = None
-
-
-class BackgroundDispatchService:
-    def __init__(self):
-        self._queued_jobs: deque[PrintDispatchJob] = deque()
-        self._dispatcher_task: asyncio.Task | None = None
-        self._running_tasks: dict[int, asyncio.Task] = {}
-        self._lock = asyncio.Lock()
-        self._job_event = asyncio.Event()
-        self._next_job_id = 1
-        self._active_jobs: dict[int, ActiveDispatchState] = {}
-        self._cancel_requested_job_ids: set[int] = set()
-
-        # Progress for the current "batch" (since queue became non-empty)
-        self._batch_total = 0
-        self._batch_completed = 0
-        self._batch_failed = 0
-
-    @staticmethod
-    def _printer_is_busy_printing(printer_id: int) -> bool:
-        state = printer_manager.get_status(printer_id)
-        if not state:
-            return False
-        return state.state in ("RUNNING", "PAUSE", "PAUSED") and bool(state.gcode_file)
-
-    async def start(self):
-        async with self._lock:
-            if self._dispatcher_task and not self._dispatcher_task.done():
-                return
-            self._dispatcher_task = asyncio.create_task(self._dispatcher_loop(), name="background-dispatch-dispatcher")
-            logger.info("Background dispatch dispatcher started")
-
-    async def stop(self):
-        dispatcher: asyncio.Task | None = None
-        running_tasks: list[asyncio.Task] = []
-        async with self._lock:
-            dispatcher = self._dispatcher_task
-            self._dispatcher_task = None
-            running_tasks = list(self._running_tasks.values())
-            self._running_tasks.clear()
-            self._active_jobs.clear()
-            self._queued_jobs.clear()
-            self._cancel_requested_job_ids.clear()
-            self._job_event.set()
-
-        if dispatcher:
-            dispatcher.cancel()
-        for task in running_tasks:
-            task.cancel()
-
-        if dispatcher:
-            try:
-                await dispatcher
-            except asyncio.CancelledError:
-                pass
-
-        if running_tasks:
-            await asyncio.gather(*running_tasks, return_exceptions=True)
-
-        logger.info("Background dispatch dispatcher stopped")
-
-    async def dispatch_reprint_archive(
-        self,
-        *,
-        archive_id: int,
-        archive_name: str,
-        printer_id: int,
-        printer_name: str,
-        options: dict[str, Any],
-        requested_by_user_id: int | None,
-        requested_by_username: str | None,
-    ) -> dict[str, Any]:
-        return await self._dispatch(
-            kind="reprint_archive",
-            source_id=archive_id,
-            source_name=archive_name,
-            printer_id=printer_id,
-            printer_name=printer_name,
-            options=options,
-            requested_by_user_id=requested_by_user_id,
-            requested_by_username=requested_by_username,
-        )
-
-    async def get_state(self) -> dict[str, Any]:
-        """Get current dispatch queue state snapshot for newly connected clients."""
-        async with self._lock:
-            return self._build_state_payload_unlocked()
-
-    async def dispatch_print_library_file(
-        self,
-        *,
-        file_id: int,
-        filename: str,
-        printer_id: int,
-        printer_name: str,
-        options: dict[str, Any],
-        requested_by_user_id: int | None,
-        requested_by_username: str | None,
-        project_id: int | None = None,
-        cleanup_library_after_dispatch: bool = False,
-    ) -> dict[str, Any]:
-        return await self._dispatch(
-            kind="print_library_file",
-            source_id=file_id,
-            source_name=filename,
-            printer_id=printer_id,
-            printer_name=printer_name,
-            options=options,
-            requested_by_user_id=requested_by_user_id,
-            requested_by_username=requested_by_username,
-            project_id=project_id,
-            cleanup_library_after_dispatch=cleanup_library_after_dispatch,
-        )
-
-    async def cancel_job(self, job_id: int) -> dict[str, Any]:
-        """Cancel a queued dispatch job.
-
-        Queued jobs are removed immediately. Active jobs are cancelled
-        cooperatively and will stop at the next cancellation checkpoint.
-        """
-        async with self._lock:
-            # Check active jobs first
-            active_state = self._active_jobs.get(job_id)
-            if active_state is not None:
-                logger.info("Cancel requested for active dispatch job %s", job_id)
-                self._cancel_requested_job_ids.add(job_id)
-                active_job = active_state.job
-                payload = self._build_state_payload_unlocked(
-                    recent_event={
-                        "status": "cancelling",
-                        "job_id": active_job.id,
-                        "source_name": active_job.source_name,
-                        "printer_id": active_job.printer_id,
-                        "printer_name": active_job.printer_name,
-                        "message": "Cancelling current dispatch...",
-                    }
-                )
-                result = {
-                    "cancelled": True,
-                    "pending": True,
-                    "job_id": active_job.id,
-                    "source_name": active_job.source_name,
-                    "printer_id": active_job.printer_id,
-                    "printer_name": active_job.printer_name,
-                }
-                await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-                return result
-
-            # Check queued jobs
-            cancelled_job: PrintDispatchJob | None = None
-            for job in self._queued_jobs:
-                if job.id == job_id:
-                    cancelled_job = job
-                    break
-
-            if not cancelled_job:
-                logger.info("Cancel requested for unknown dispatch job %s", job_id)
-                return {"cancelled": False, "reason": "not_found"}
-
-            self._queued_jobs.remove(cancelled_job)
-            logger.info("Cancelled queued dispatch job %s", cancelled_job.id)
-            self._batch_total = max(0, self._batch_total - 1)
-
-            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
-                self._batch_completed = 0
-                self._batch_failed = 0
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "cancelled",
-                    "job_id": cancelled_job.id,
-                    "source_name": cancelled_job.source_name,
-                    "printer_id": cancelled_job.printer_id,
-                    "printer_name": cancelled_job.printer_name,
-                    "message": "Cancelled from queue",
-                }
-            )
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-        return {
-            "cancelled": True,
-            "pending": False,
-            "job_id": cancelled_job.id,
-            "source_name": cancelled_job.source_name,
-            "printer_id": cancelled_job.printer_id,
-            "printer_name": cancelled_job.printer_name,
-        }
-
-    async def _dispatch(
-        self,
-        *,
-        kind: Literal["reprint_archive", "print_library_file"],
-        source_id: int,
-        source_name: str,
-        printer_id: int,
-        printer_name: str,
-        options: dict[str, Any],
-        requested_by_user_id: int | None,
-        requested_by_username: str | None,
-        project_id: int | None = None,
-        cleanup_library_after_dispatch: bool = False,
-    ) -> dict[str, Any]:
-        async with self._lock:
-            has_pending_for_printer = any(job.printer_id == printer_id for job in self._queued_jobs)
-            has_active_for_printer = any(active.job.printer_id == printer_id for active in self._active_jobs.values())
-
-            if has_pending_for_printer or has_active_for_printer:
-                raise DispatchEnqueueRejected(f"Printer {printer_name} already has a background dispatch in progress")
-
-            if self._printer_is_busy_printing(printer_id):
-                raise DispatchEnqueueRejected(f"Printer {printer_name} is currently busy printing")
-
-            dispatch_position = len(self._queued_jobs) + len(self._active_jobs) + 1
-            job = PrintDispatchJob(
-                id=self._next_job_id,
-                kind=kind,
-                source_id=source_id,
-                source_name=source_name,
-                printer_id=printer_id,
-                printer_name=printer_name,
-                options=options,
-                requested_by_user_id=requested_by_user_id,
-                requested_by_username=requested_by_username,
-                project_id=project_id,
-                cleanup_library_after_dispatch=cleanup_library_after_dispatch,
-            )
-            self._next_job_id += 1
-            self._batch_total += 1
-            self._queued_jobs.append(job)
-            self._job_event.set()
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "dispatched",
-                    "job_id": job.id,
-                    "source_name": source_name,
-                    "printer_id": printer_id,
-                    "printer_name": printer_name,
-                    "message": f"Dispatched to {printer_name}",
-                }
-            )
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-        return {
-            "dispatch_job_id": job.id,
-            "dispatch_position": dispatch_position,
-            "status": "dispatched",
-            "printer_id": printer_id,
-            "source_id": source_id,
-            "source_name": source_name,
-        }
-
-    async def _dispatcher_loop(self):
-        while True:
-            await self._job_event.wait()
-            self._job_event.clear()
-
-            while True:
-                payload: dict[str, Any] | None = None
-                job_to_start: PrintDispatchJob | None = None
-                async with self._lock:
-                    busy_printer_ids = {state.job.printer_id for state in self._active_jobs.values()}
-                    start_index = next(
-                        (
-                            idx
-                            for idx, queued_job in enumerate(self._queued_jobs)
-                            if queued_job.printer_id not in busy_printer_ids
-                        ),
-                        None,
-                    )
-
-                    if start_index is None:
-                        break
-
-                    job_to_start = self._queued_jobs[start_index]
-                    del self._queued_jobs[start_index]
-                    self._active_jobs[job_to_start.id] = ActiveDispatchState(
-                        job=job_to_start,
-                        message="Preparing background dispatch...",
-                    )
-
-                    task = asyncio.create_task(
-                        self._run_active_job(job_to_start), name=f"background-dispatch-job-{job_to_start.id}"
-                    )
-                    self._running_tasks[job_to_start.id] = task
-
-                    payload = self._build_state_payload_unlocked(
-                        recent_event={
-                            "status": "processing",
-                            "job_id": job_to_start.id,
-                            "source_name": job_to_start.source_name,
-                            "printer_id": job_to_start.printer_id,
-                            "printer_name": job_to_start.printer_name,
-                            "message": "Preparing background dispatch...",
-                        }
-                    )
-
-                if payload:
-                    await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    async def _run_active_job(self, job: PrintDispatchJob):
-        try:
-            await self._process_job(job)
-            await self._mark_job_finished(job, failed=False, message="Background dispatch complete")
-        except DispatchJobCancelled:
-            await self._mark_job_cancelled(job)
-        except asyncio.CancelledError:
-            raise
-        except Exception as e:
-            logger.error("Background dispatch job %s failed: %s", job.id, e, exc_info=True)
-            await self._mark_job_finished(job, failed=True, message=str(e))
-        finally:
-            self._job_event.set()
-
-    async def _set_active_message(self, job: PrintDispatchJob, message: str):
-        async with self._lock:
-            active = self._active_jobs.get(job.id)
-            if not active:
-                return
-            active.message = message
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "processing",
-                    "job_id": active.job.id,
-                    "source_name": active.job.source_name,
-                    "printer_id": active.job.printer_id,
-                    "printer_name": active.job.printer_name,
-                    "message": message,
-                }
-            )
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    async def _set_active_upload_progress(self, job: PrintDispatchJob, uploaded: int, total: int):
-        async with self._lock:
-            active = self._active_jobs.get(job.id)
-            if not active:
-                return
-
-            active.upload_bytes = max(0, int(uploaded))
-            active.upload_total_bytes = max(0, int(total))
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "processing",
-                    "job_id": active.job.id,
-                    "source_name": active.job.source_name,
-                    "printer_id": active.job.printer_id,
-                    "printer_name": active.job.printer_name,
-                    "message": active.message,
-                }
-            )
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    async def _mark_job_finished(self, job: PrintDispatchJob, *, failed: bool, message: str):
-        async with self._lock:
-            if failed:
-                self._batch_failed += 1
-            else:
-                self._batch_completed += 1
-
-            self._active_jobs.pop(job.id, None)
-            self._running_tasks.pop(job.id, None)
-            self._cancel_requested_job_ids.discard(job.id)
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "failed" if failed else "completed",
-                    "job_id": job.id,
-                    "source_name": job.source_name,
-                    "printer_id": job.printer_id,
-                    "printer_name": job.printer_name,
-                    "message": message,
-                }
-            )
-            should_reset_batch = len(self._queued_jobs) == 0 and len(self._active_jobs) == 0
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-        if should_reset_batch:
-            async with self._lock:
-                if len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
-                    self._batch_total = 0
-                    self._batch_completed = 0
-                    self._batch_failed = 0
-
-    async def _mark_job_cancelled(self, job: PrintDispatchJob):
-        async with self._lock:
-            self._active_jobs.pop(job.id, None)
-            self._running_tasks.pop(job.id, None)
-            self._cancel_requested_job_ids.discard(job.id)
-            self._batch_total = max(0, self._batch_total - 1)
-
-            if self._batch_total == 0 and len(self._queued_jobs) == 0 and len(self._active_jobs) == 0:
-                self._batch_completed = 0
-                self._batch_failed = 0
-
-            payload = self._build_state_payload_unlocked(
-                recent_event={
-                    "status": "cancelled",
-                    "job_id": job.id,
-                    "source_name": job.source_name,
-                    "printer_id": job.printer_id,
-                    "printer_name": job.printer_name,
-                    "message": "Cancelled during dispatch",
-                }
-            )
-
-        await ws_manager.broadcast({"type": "background_dispatch", "data": payload})
-
-    def _is_cancel_requested(self, job_id: int) -> bool:
-        return job_id in self._cancel_requested_job_ids
-
-    def _raise_if_cancel_requested(self, job: PrintDispatchJob):
-        if self._is_cancel_requested(job.id):
-            raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled")
-
-    def _build_state_payload_unlocked(self, recent_event: dict[str, Any] | None = None) -> dict[str, Any]:
-        processing = len(self._active_jobs)
-        dispatched = len(self._queued_jobs)
-
-        dispatched_jobs = [
-            {
-                "job_id": job.id,
-                "kind": job.kind,
-                "source_id": job.source_id,
-                "source_name": job.source_name,
-                "printer_id": job.printer_id,
-                "printer_name": job.printer_name,
-            }
-            for job in list(self._queued_jobs)
-        ]
-
-        active_jobs: list[dict[str, Any]] = []
-        for active in self._active_jobs.values():
-            upload_progress_pct = None
-            if active.upload_total_bytes and active.upload_total_bytes > 0 and active.upload_bytes is not None:
-                upload_progress_pct = round(
-                    max(0.0, min(100.0, (active.upload_bytes / active.upload_total_bytes) * 100.0)), 1
-                )
-
-            active_jobs.append(
-                {
-                    "job_id": active.job.id,
-                    "kind": active.job.kind,
-                    "source_id": active.job.source_id,
-                    "source_name": active.job.source_name,
-                    "printer_id": active.job.printer_id,
-                    "printer_name": active.job.printer_name,
-                    "message": active.message,
-                    "upload_bytes": active.upload_bytes,
-                    "upload_total_bytes": active.upload_total_bytes,
-                    "upload_progress_pct": upload_progress_pct,
-                }
-            )
-
-        active_jobs.sort(key=lambda item: int(item["job_id"]))
-        active_job = active_jobs[0] if active_jobs else None
-
-        return {
-            "total": self._batch_total,
-            "dispatched": dispatched,
-            "processing": processing,
-            "completed": self._batch_completed,
-            "failed": self._batch_failed,
-            "dispatched_jobs": dispatched_jobs,
-            "active_jobs": active_jobs,
-            "active_job": active_job,
-            "recent_event": recent_event,
-        }
-
-    async def _process_job(self, job: PrintDispatchJob):
-        if job.kind == "reprint_archive":
-            await self._run_reprint_archive(job)
-            return
-        if job.kind == "print_library_file":
-            await self._run_print_library_file(job)
-            return
-        raise RuntimeError(f"Unknown dispatch job kind: {job.kind}")
-
-    async def _run_reprint_archive(self, job: PrintDispatchJob):
-        from backend.app.main import register_expected_print
-
-        async with async_session() as db:
-            service = ArchiveService(db)
-            archive = await service.get_archive(job.source_id)
-            if not archive:
-                raise RuntimeError("Archive not found")
-
-            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
-            if not printer:
-                raise RuntimeError("Printer not found")
-
-            printer_name = printer.name
-            printer_ip = printer.ip_address
-            printer_access_code = printer.access_code
-            printer_model = printer.model
-            archive_filename = archive.filename
-
-            if not printer_manager.is_connected(job.printer_id):
-                raise RuntimeError("Printer is not connected")
-
-            file_path = settings.base_dir / archive.file_path
-            if not file_path.exists():
-                raise RuntimeError("Archive file not found")
-
-            remote_filename = derive_remote_filename(archive.filename)
-            remote_path = f"/{remote_filename}"
-
-            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-            self._raise_if_cancel_requested(job)
-
-            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
-            await delete_file_async(
-                printer_ip,
-                printer_access_code,
-                remote_path,
-                socket_timeout=ftp_timeout,
-                printer_model=printer_model,
-            )
-
-            self._raise_if_cancel_requested(job)
-
-            try:
-                await self._set_active_message(job, f"Uploading {archive_filename} to {printer_name}...")
-                loop = asyncio.get_running_loop()
-                progress_state = {"last_emit": 0.0, "last_bytes": 0}
-
-                def upload_progress_callback(uploaded: int, total: int):
-                    if self._is_cancel_requested(job.id):
-                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
-
-                    now = time.monotonic()
-                    should_emit = (
-                        uploaded >= total
-                        or now - progress_state["last_emit"] >= 0.2
-                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
-                    )
-
-                    if should_emit:
-                        progress_state["last_emit"] = now
-                        progress_state["last_bytes"] = uploaded
-                        loop.call_soon_threadsafe(
-                            lambda u=uploaded, t=total: spawn_background_task(
-                                self._set_active_upload_progress(job, u, t),
-                                name=f"upload-progress-{job.id}",
-                            )
-                        )
-
-                if ftp_retry_enabled:
-                    uploaded = await with_ftp_retry(
-                        upload_file_async,
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                        max_retries=ftp_retry_count,
-                        retry_delay=ftp_retry_delay,
-                        operation_name=f"Upload for reprint to {printer_name}",
-                        non_retry_exceptions=(DispatchJobCancelled,),
-                    )
-                else:
-                    uploaded = await upload_file_async(
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                    )
-
-                if uploaded:
-                    await self._set_active_upload_progress(job, 1, 1)
-
-                if not uploaded:
-                    raise RuntimeError(
-                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
-                    )
-
-                # Resolve plate_id before register so usage tracking can scope the
-                # 3MF parse to the dispatched plate at print-start (#1697). Pure
-                # transform of file_path + options, safe to reorder.
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
-                register_expected_print(
-                    job.printer_id,
-                    remote_filename,
-                    job.source_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    plate_id=plate_id,
-                )
-
-                self._raise_if_cancel_requested(job)
-
-                effective_timelapse = bool(job.options.get("timelapse", False))
-
-                await self._set_active_message(job, f"Starting print on {printer_name}...")
-                started = printer_manager.start_print(
-                    job.printer_id,
-                    remote_filename,
-                    plate_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=effective_timelapse,
-                    bed_levelling=job.options.get("bed_levelling", True),
-                    flow_cali=job.options.get("flow_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),
-                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
-                )
-
-                if not started:
-                    await self._cleanup_sd_card_file(
-                        printer_ip,
-                        printer_access_code,
-                        remote_path,
-                        printer_model,
-                    )
-                    raise RuntimeError("Failed to start print")
-
-                # Register the archive's local 3MF in the cover-cache so the
-                # /cover endpoint can skip FTP — we already have the file on
-                # disk, no need to refetch 36 MB from a printer whose FTP is
-                # busy serving the active print (#1166 follow-up).
-                cache_3mf_download(job.printer_id, remote_filename, file_path)
-
-                # Wait for the printer to actually pick up the command before
-                # marking the dispatch job complete (#1042). MQTT-publish success
-                # only proves the command queued locally; the printer can still
-                # reject it (HMS error pending, half-broken session, SD card
-                # missing) and never transition. Until #1042 this watchdog was
-                # fire-and-forget — the job was reported successful and the
-                # user had no signal that the print never started. The uploaded
-                # file is intentionally left on the printer's SD card on
-                # timeout: the next dispatch will overwrite it via the existing
-                # delete-then-upload step, and the printer may still be in the
-                # middle of reading it if it picked up just past the timeout.
-                pre_status = printer_manager.get_status(job.printer_id)
-                pre_state = getattr(pre_status, "state", None) if pre_status else None
-                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
-                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
-                if pre_state:
-                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
-                    transitioned = await self._verify_print_response(
-                        job.printer_id,
-                        printer_name,
-                        pre_state,
-                        pre_subtask_id=pre_subtask_id,
-                        pre_gcode_file=pre_gcode_file,
-                    )
-                    if not transitioned:
-                        raise RuntimeError(
-                            f"Printer did not acknowledge print command — state still {pre_state}. "
-                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
-                            f"SD card) and try again."
-                        )
-
-                if job.requested_by_user_id and job.requested_by_username:
-                    printer_manager.set_current_print_user(
-                        job.printer_id,
-                        job.requested_by_user_id,
-                        job.requested_by_username,
-                    )
-            except DispatchJobCancelled:
-                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
-                raise
-
-    async def _run_print_library_file(self, job: PrintDispatchJob):
-        from backend.app.main import register_expected_print
-
-        async with async_session() as db:
-            lib_file = await db.scalar(LibraryFile.active().where(LibraryFile.id == job.source_id))
-            if not lib_file:
-                raise RuntimeError("File not found")
-
-            if not self._is_sliced_file(lib_file.filename):
-                raise RuntimeError("Not a sliced file. Only .gcode or .gcode.3mf files can be printed.")
-
-            file_path = Path(settings.base_dir) / lib_file.file_path
-            if not file_path.exists():
-                raise RuntimeError("File not found on disk")
-
-            printer = await db.scalar(select(Printer).where(Printer.id == job.printer_id))
-            if not printer:
-                raise RuntimeError("Printer not found")
-
-            printer_name = printer.name
-            printer_ip = printer.ip_address
-            printer_access_code = printer.access_code
-            printer_model = printer.model
-            library_filename = lib_file.filename
-
-            if not printer_manager.is_connected(job.printer_id):
-                raise RuntimeError("Printer is not connected")
-
-            await self._set_active_message(job, f"Creating archive for {lib_file.filename}...")
-            archive_service = ArchiveService(db)
-            archive = await archive_service.archive_print(
-                printer_id=job.printer_id,
-                source_file=file_path,
-                original_filename=lib_file.filename,
-                project_id=job.project_id,
-                created_by_id=job.requested_by_user_id,
-            )
-            if not archive:
-                raise RuntimeError("Failed to create archive")
-
-            await db.flush()
-
-            remote_filename = derive_remote_filename(lib_file.filename)
-            remote_path = f"/{remote_filename}"
-
-            ftp_retry_enabled, ftp_retry_count, ftp_retry_delay, ftp_timeout = await get_ftp_retry_settings()
-            self._raise_if_cancel_requested(job)
-
-            await self._set_active_message(job, f"Preparing upload to {printer_name}...")
-            await delete_file_async(
-                printer_ip,
-                printer_access_code,
-                remote_path,
-                socket_timeout=ftp_timeout,
-                printer_model=printer_model,
-            )
-
-            self._raise_if_cancel_requested(job)
-
-            try:
-                await self._set_active_message(job, f"Uploading {library_filename} to {printer_name}...")
-                loop = asyncio.get_running_loop()
-                progress_state = {"last_emit": 0.0, "last_bytes": 0}
-
-                def upload_progress_callback(uploaded: int, total: int):
-                    if self._is_cancel_requested(job.id):
-                        raise DispatchJobCancelled(f"Dispatch job {job.id} cancelled during upload")
-
-                    now = time.monotonic()
-                    should_emit = (
-                        uploaded >= total
-                        or now - progress_state["last_emit"] >= 0.2
-                        or uploaded - progress_state["last_bytes"] >= 256 * 1024
-                    )
-
-                    if should_emit:
-                        progress_state["last_emit"] = now
-                        progress_state["last_bytes"] = uploaded
-                        loop.call_soon_threadsafe(
-                            lambda u=uploaded, t=total: spawn_background_task(
-                                self._set_active_upload_progress(job, u, t),
-                                name=f"upload-progress-{job.id}",
-                            )
-                        )
-
-                if ftp_retry_enabled:
-                    uploaded = await with_ftp_retry(
-                        upload_file_async,
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                        max_retries=ftp_retry_count,
-                        retry_delay=ftp_retry_delay,
-                        operation_name=f"Upload for print to {printer_name}",
-                        non_retry_exceptions=(DispatchJobCancelled,),
-                    )
-                else:
-                    uploaded = await upload_file_async(
-                        printer_ip,
-                        printer_access_code,
-                        file_path,
-                        remote_path,
-                        progress_callback=upload_progress_callback,
-                        socket_timeout=ftp_timeout,
-                        printer_model=printer_model,
-                    )
-
-                if uploaded:
-                    await self._set_active_upload_progress(job, 1, 1)
-
-                if not uploaded:
-                    await db.rollback()
-                    raise RuntimeError(
-                        "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT)."
-                    )
-
-                # Resolve plate_id before register so usage tracking can scope the
-                # 3MF parse to the dispatched plate at print-start (#1697).
-                plate_id = self._resolve_plate_id(file_path, job.options.get("plate_id"))
-
-                register_expected_print(
-                    job.printer_id,
-                    remote_filename,
-                    archive.id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    plate_id=plate_id,
-                )
-
-                self._raise_if_cancel_requested(job)
-
-                effective_timelapse = bool(job.options.get("timelapse", False))
-
-                await self._set_active_message(job, f"Starting print on {printer_name}...")
-                started = printer_manager.start_print(
-                    job.printer_id,
-                    remote_filename,
-                    plate_id,
-                    ams_mapping=job.options.get("ams_mapping"),
-                    timelapse=effective_timelapse,
-                    bed_levelling=job.options.get("bed_levelling", True),
-                    flow_cali=job.options.get("flow_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),
-                    nozzle_offset_cali=job.options.get("nozzle_offset_cali", False),
-                )
-
-                if not started:
-                    await self._cleanup_sd_card_file(
-                        printer_ip,
-                        printer_access_code,
-                        remote_path,
-                        printer_model,
-                    )
-                    await db.rollback()
-                    raise RuntimeError("Failed to start print")
-
-                # Same as the archive path: register the library file's local
-                # 3MF in the cover-cache so /cover skips FTP (#1166 follow-up).
-                cache_3mf_download(job.printer_id, remote_filename, file_path)
-
-                # See _run_reprint_archive for rationale (#1042). On timeout
-                # also rolls back the freshly-created archive so the library
-                # flow doesn't leave behind a phantom row for a print that
-                # never started.
-                pre_status = printer_manager.get_status(job.printer_id)
-                pre_state = getattr(pre_status, "state", None) if pre_status else None
-                pre_subtask_id = getattr(pre_status, "subtask_id", None) if pre_status else None
-                pre_gcode_file = getattr(pre_status, "gcode_file", None) if pre_status else None
-                if pre_state:
-                    await self._set_active_message(job, f"Waiting for {printer_name} to acknowledge print...")
-                    transitioned = await self._verify_print_response(
-                        job.printer_id,
-                        printer_name,
-                        pre_state,
-                        pre_subtask_id=pre_subtask_id,
-                        pre_gcode_file=pre_gcode_file,
-                    )
-                    if not transitioned:
-                        await db.rollback()
-                        raise RuntimeError(
-                            f"Printer did not acknowledge print command — state still {pre_state}. "
-                            f"Check the printer for a pending error (HMS code, plate-clear prompt, "
-                            f"SD card) and try again."
-                        )
-
-                if job.requested_by_user_id and job.requested_by_username:
-                    printer_manager.set_current_print_user(
-                        job.printer_id,
-                        job.requested_by_user_id,
-                        job.requested_by_username,
-                    )
-
-                # Direct-Print flow only: archive_print copies, so deleting the
-                # transient library row + files here leaves archive intact. Disk
-                # deletes run only after commit so a rollback leaves no orphan.
-                cleanup_disk_paths: list[Path] = []
-                if job.cleanup_library_after_dispatch and not lib_file.is_external:
-                    cleanup_disk_paths.append(file_path)
-                    if lib_file.thumbnail_path:
-                        thumb_path = Path(lib_file.thumbnail_path)
-                        if not thumb_path.is_absolute():
-                            thumb_path = Path(settings.base_dir) / lib_file.thumbnail_path
-                        cleanup_disk_paths.append(thumb_path)
-                    await db.delete(lib_file)
-
-                await db.commit()
-
-                for cleanup_path in cleanup_disk_paths:
-                    try:
-                        if cleanup_path.exists():
-                            cleanup_path.unlink()
-                    except OSError as cleanup_err:
-                        logger.warning("Failed to delete transient library file %s: %s", cleanup_path, cleanup_err)
-            except DispatchJobCancelled:
-                await db.rollback()
-                await self._set_active_message(job, f"Cancelled upload on {printer_name}.")
-                raise
-
-    @staticmethod
-    async def _verify_print_response(
-        printer_id: int,
-        printer_name: str,
-        pre_state: str,
-        pre_subtask_id: str | None = None,
-        pre_gcode_file: str | None = None,
-        timeout: float = 90.0,
-        poll_interval: float = 3.0,
-    ) -> bool:
-        """Wait for the printer to acknowledge a print command.
-
-        Returns True if the printer transitioned (state advanced past pre_state
-        or subtask_id advanced past pre_subtask_id). Returns False on timeout —
-        in that case logs a warning and forces an MQTT reconnect, mirroring the
-        queue-side watchdog (`_watchdog_print_start`). Caller is responsible
-        for surfacing the False result to the user (typically by raising so the
-        dispatch job is marked failed).
-
-        Both transition signals are checked because H2D can sit at FINISH for
-        ~50 s after accepting `project_file` before flipping to PREPARE; the
-        printer echoes our per-dispatch identity back as `subtask_id` on
-        `push_status` first, so a subtask_id change is a definitive "command
-        landed" signal even while state is still FINISH (#1078).
-        """
-        deadline = time.monotonic() + timeout
-        last_status = None
-        while time.monotonic() < deadline:
-            await asyncio.sleep(poll_interval)
-            state = printer_manager.get_status(printer_id)
-            if not state:
-                # Printer momentarily not reporting — could be a brief MQTT
-                # disconnect mid-window. Keep polling rather than declaring
-                # failure on the first missed tick; the printer may reconnect
-                # within the remaining timeout and still surface a transition.
-                continue
-            last_status = state
-            if state.state in _ACTIVE_PRINT_STATES:
-                # Printer is actively processing the job. We do NOT accept
-                # arbitrary state transitions: a printer going FINISH -> IDLE
-                # (user dismissed the post-print prompt without accepting our
-                # project_file) would otherwise look like "command landed"
-                # and the dispatch job would be marked successful even though
-                # no print is running (#1370).
-                return True
-            if pre_subtask_id is not None and state.subtask_id is not None and state.subtask_id != pre_subtask_id:
-                # Printer picked up the job (subtask_id advanced). H2D can
-                # sit at FINISH for ~50 s after accepting project_file before
-                # transitioning to PREPARE, but the subtask_id flips to our
-                # submission_id almost immediately (#1078).
-                return True
-        logger.warning(
-            "Printer %s (%d) did not respond to print command within %.0fs "
-            "(state still %s, subtask_id still %s) — printer may need restart",
-            printer_name,
-            printer_id,
-            timeout,
-            pre_state,
-            pre_subtask_id,
-        )
-        # Distinguish #1150 (slow parse) from #887/#936 (half-broken session)
-        # via gcode_file: if the printer is now showing a different file than
-        # before dispatch, the project_file command landed and the printer is
-        # parsing — a forced reconnect mid-parse causes 0500_4003. If
-        # gcode_file is unchanged, the publish was silently swallowed and the
-        # original #936 recovery (force_reconnect → fresh client_id) is what
-        # we want. Caveat: in the rare retry-same-file-after-timeout case the
-        # printer's gcode_file looks identical before and after the publish
-        # lands, so a slow parse on retry-same-file still falls through to the
-        # reconnect (and the original 0500_4003) — accepted to avoid breaking
-        # the half-broken-session recovery path.
-        client = printer_manager.get_client(printer_id)
-        current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
-        publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file
-        if publish_landed:
-            logger.warning(
-                "Printer %s (%d) gcode_file changed to %r (was %r) — printer "
-                "received the command and is parsing slowly. Skipping forced "
-                "MQTT reconnect to avoid 0500_4003 mid-parse (#1150).",
-                printer_name,
-                printer_id,
-                current_gcode_file,
-                pre_gcode_file,
-            )
-        elif client and hasattr(client, "force_reconnect_stale_session"):
-            client.force_reconnect_stale_session(
-                f"print command unacknowledged after {timeout:.0f}s "
-                f"(state still {pre_state}, gcode_file {current_gcode_file!r})"
-            )
-        return False
-
-    @staticmethod
-    async def _cleanup_sd_card_file(
-        printer_ip: str,
-        access_code: str,
-        remote_path: str,
-        printer_model: str | None,
-    ):
-        """Best-effort delete of uploaded file from printer SD card."""
-        try:
-            await delete_file_async(printer_ip, access_code, remote_path, printer_model=printer_model)
-        except Exception:
-            pass  # Best-effort — don't fail the error handler
-
-    @staticmethod
-    def _resolve_plate_id(file_path: Path, requested_plate_id: int | None) -> int:
-        if requested_plate_id is not None:
-            return requested_plate_id
-
-        plate_id = 1
-        try:
-            with zipfile.ZipFile(file_path, "r") as zf:
-                for name in zf.namelist():
-                    if name.startswith("Metadata/plate_") and name.endswith(".gcode"):
-                        plate_str = name[15:-6]
-                        plate_id = int(plate_str)
-                        break
-        except (ValueError, zipfile.BadZipFile, OSError):
-            pass
-        return plate_id
-
-    @staticmethod
-    def _is_sliced_file(filename: str) -> bool:
-        lower = filename.lower()
-        return lower.endswith(".gcode") or lower.endswith(".gcode.3mf")
-
-
-background_dispatch = BackgroundDispatchService()

+ 466 - 61
backend/app/services/bambu_mqtt.py

@@ -21,6 +21,8 @@ from datetime import datetime, timezone
 
 import paho.mqtt.client as mqtt
 
+from backend.app.services.hms_actions import HMSAction, get_actions_for_error_code
+
 logger = logging.getLogger(__name__)
 
 # AMS module name prefixes used in get_version responses.
@@ -31,6 +33,23 @@ logger = logging.getLogger(__name__)
 _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 
 
+def parse_ams_filament_backup_from_cfg(cfg_raw: object) -> bool | None:
+    """Extract AMS Filament Backup state from a Bambu push_status ``print.cfg`` value.
+
+    OrcaSlicer reads bit 18 of the hex string via
+    ``get_flag_bits(cfg, 18)`` (DeviceManager.cpp:4961). Old-protocol families
+    (A1 / A1 Mini) omit ``cfg`` entirely; this returns ``None`` for any input
+    that doesn't yield a clean integer so downstream consumers preserve today's
+    behaviour rather than treating "absent" as "OFF".
+    """
+    if not isinstance(cfg_raw, str) or not cfg_raw:
+        return None
+    try:
+        return bool((int(cfg_raw, 16) >> 18) & 1)
+    except ValueError:
+        return None
+
+
 def apply_tray_exist_bits(
     units: list,
     tray_exist_bits_str: str | int | None,
@@ -152,6 +171,22 @@ class HMSError:
     module: int
     severity: int  # 1=fatal, 2=serious, 3=common, 4=info
     message: str = ""
+    # User-facing remediation actions from the bundled HMS catalog (e.g. "RESUME_PRINTING",
+    # "CHECK_ASSISTANT"). Defaults to an empty list rather than None so the field always
+    # satisfies HMSErrorResponse.actions: list[str] — a future code path that builds an
+    # HMSError without explicitly passing actions can't silently land None on the schema
+    # boundary and raise ValidationError at routes/printers.py response time.
+    actions: list[str] = field(default_factory=list)
+    # The `subtask_id` snapshotted from PrinterState when this error surfaced; Bambu's
+    # HMS-aware commands echo it back as `job_id`. None for idle errors with no job.
+    job_id: str | None = None
+    # Canonical hex identifier for the firmware's `err` matching: 16 chars for the
+    # 64-bit `hms[]` array path (`f"{attr:08X}{code:08X}"`), 8 chars for the
+    # 32-bit `print_error` path. The frontend echoes this back to
+    # execute_hms_action; the truncated 8-char short code that `_parse_status`
+    # used to send caused the firmware to silently reject HMS commands on H2C
+    # (#1830) and on `hms[]`-sourced faults generally.
+    full_code: str = ""
 
 
 # HMS short codes the firmware emits during normal user-cancel sequences.
@@ -330,6 +365,11 @@ class PrinterState:
     # Developer LAN mode: parsed from MQTT "fun" field bit 0x20000000
     # True = dev mode ON (no encryption), False = dev mode OFF (encryption required), None = unknown
     developer_mode: bool | None = None
+    # AMS Filament Backup: bit 18 of top-level print.cfg hex on new-protocol Bambu
+    # printers (H/X/P/H2 families). True=ON, False=OFF, None=unknown (e.g. A1 family
+    # which uses the old protocol path; field not yet found). Consumers must treat
+    # None as "no opinion" — preserving today's behaviour, NOT as "disabled".
+    ams_filament_backup: bool | None = None
 
 
 # Stage name mapping from BambuStudio DeviceManager.cpp
@@ -474,6 +514,11 @@ class BambuMQTTClient:
         # 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] = {}
+        # Per-AMS active-cycle target params (filament + temp) we sent on the
+        # last start. Bambu does not echo these back in the per-tick AMS push
+        # — only the dry_time countdown — so we cache what we sent to drive
+        # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
+        self._drying_targets: dict[int, dict[str, object]] = {}
 
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
@@ -653,7 +698,7 @@ class BambuMQTTClient:
         #
         # Two routing paths:
         #
-        # Async-context callers (background_dispatch.py:993 — dispatch deadline)
+        # Async-context callers (queue dispatch deadline)
         #   → full client teardown + fresh client_id. Wipes paho's client-side
         #     QoS 1 queue, which is exactly the #1136 reproducer: an unacked
         #     `project_file` from the broken session would otherwise replay on
@@ -1018,6 +1063,33 @@ class BambuMQTTClient:
                     f"gcode_file: {print_data.get('gcode_file')}, subtask_name: {print_data.get('subtask_name')}"
                 )
 
+            # AMS Filament Backup state lives in bit 18 of top-level print.cfg on
+            # new-protocol printers. Verified against OrcaSlicer's
+            # DeviceManager.cpp:4961 SetAutoRefillEnabled(get_flag_bits(cfg, 18))
+            # and live H2D ON/OFF capture 2026-06-20.
+            #
+            # Hold-timer guard: when the user just toggled via the badge, the
+            # next 1-2 push_status frames may still carry the printer's OLD cfg
+            # for ~3 s before the firmware reflects the change. Without this
+            # gate the UI would flicker ON→OFF→ON. Same pattern xcam uses.
+            new_backup = parse_ams_filament_backup_from_cfg(print_data.get("cfg"))
+            if new_backup is not None and new_backup != self.state.ams_filament_backup:
+                hold_start = self._xcam_hold_start.get("print_option_auto_switch_filament")
+                if hold_start is not None and (time.time() - hold_start) <= self._xcam_hold_time:
+                    logger.debug(
+                        "[%s] AMS Filament Backup push ignored (hold active for %.1fs)",
+                        self.serial_number,
+                        time.time() - hold_start,
+                    )
+                else:
+                    logger.info(
+                        "[%s] AMS Filament Backup: %s",
+                        self.serial_number,
+                        "ON" if new_backup else "OFF",
+                    )
+                    self.state.ams_filament_backup = new_backup
+                    self._xcam_hold_start.pop("print_option_auto_switch_filament", None)
+
             # Detect dual-nozzle BEFORE processing AMS data (tray_now disambiguation needs it)
             # device.extruder.info with >= 2 entries only exists on dual-nozzle printers (H2D, H2D Pro)
             if not self._is_dual_nozzle and "device" in print_data:
@@ -1717,35 +1789,55 @@ class BambuMQTTClient:
                                 self.state.tray_now = parsed_tray_now
                 elif not self._is_dual_nozzle and 0 <= parsed_tray_now <= 3:
                     # Single-nozzle printer with tray_now in 0-3 range.
-                    # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
-                    # in tray_now, not global tray IDs (#420). Use the MQTT mapping field
-                    # (snow-encoded) to resolve the correct AMS unit.
-                    ams_exist_raw = ams_data.get("ams_exist_bits", "0")
-                    try:
-                        ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
-                    except (ValueError, TypeError):
-                        ams_exist = 0
-                    num_ams = bin(ams_exist).count("1")
-
-                    if num_ams > 1:
-                        # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
-                        # Cross-reference with MQTT mapping field to find the correct AMS unit.
-                        mapping_raw = self.state.raw_data.get("mapping")
-                        resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
-                        if resolved is not None:
-                            if resolved != parsed_tray_now:
-                                logger.debug(
-                                    f"[{self.serial_number}] Multi-AMS tray_now: "
-                                    f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
-                                )
-                            self.state.tray_now = resolved
+                    # #1822: H2S firmware reports tray_now as the AMS's idle
+                    # slot (typically 0) when the active feed is actually the
+                    # external spool. X1C / P1S / A1 correctly report 254 in
+                    # that case; H2S does not. When the slicer-captured
+                    # ams_mapping is all-external (every entry == -1), the
+                    # print can only be feeding from the external spool, so
+                    # promote tray_now to 254. Mixed (e.g. [5, -1]) and
+                    # AMS-only mappings are NOT overridden — there's no
+                    # evidence the firmware misreports in those cases. Prints
+                    # started without a captured mapping (printer-screen start,
+                    # or before Bambuddy connected) fall through unchanged.
+                    captured = self._captured_ams_mapping
+                    if captured and all(s == -1 for s in captured):
+                        if self.state.tray_now != 254:
+                            logger.debug(
+                                f"[{self.serial_number}] tray_now external-spool override (#1822): "
+                                f"slot {parsed_tray_now} -> 254 (ams_mapping={captured})"
+                            )
+                        self.state.tray_now = 254
+                    else:
+                        # P2S (and possibly other models) with multiple AMS units sends LOCAL slot IDs
+                        # in tray_now, not global tray IDs (#420). Use the MQTT mapping field
+                        # (snow-encoded) to resolve the correct AMS unit.
+                        ams_exist_raw = ams_data.get("ams_exist_bits", "0")
+                        try:
+                            ams_exist = int(ams_exist_raw, 16) if isinstance(ams_exist_raw, str) else int(ams_exist_raw)
+                        except (ValueError, TypeError):
+                            ams_exist = 0
+                        num_ams = bin(ams_exist).count("1")
+
+                        if num_ams > 1:
+                            # Multiple AMS on single-nozzle — tray_now is likely a local slot ID.
+                            # Cross-reference with MQTT mapping field to find the correct AMS unit.
+                            mapping_raw = self.state.raw_data.get("mapping")
+                            resolved = self._resolve_local_slot_from_mapping(parsed_tray_now, mapping_raw)
+                            if resolved is not None:
+                                if resolved != parsed_tray_now:
+                                    logger.debug(
+                                        f"[{self.serial_number}] Multi-AMS tray_now: "
+                                        f"local slot {parsed_tray_now} -> global ID {resolved} (from mapping)"
+                                    )
+                                self.state.tray_now = resolved
+                            else:
+                                # No mapping available (not printing, or ambiguous) — use as-is.
+                                # This matches the old behavior and is correct for AMS 0.
+                                self.state.tray_now = parsed_tray_now
                         else:
-                            # No mapping available (not printing, or ambiguous) — use as-is.
-                            # This matches the old behavior and is correct for AMS 0.
+                            # Single AMS — local slot 0-3 equals global ID
                             self.state.tray_now = parsed_tray_now
-                    else:
-                        # Single AMS — local slot 0-3 equals global ID
-                        self.state.tray_now = parsed_tray_now
                 else:
                     # tray_now > 3 means it's already a global ID, or 255 means unloaded
                     # Note: Do NOT clear pending_tray_target on tray_now=255 here.
@@ -1987,38 +2079,40 @@ class BambuMQTTClient:
 
         # 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
-                # Only evaluate the edge when this update carries an explicit
-                # dry_time. An absent / unparseable value is NOT zero — treating
-                # it as 0 lets a tray-only partial fake a drying-complete edge
-                # (#1462). Skip without touching the remembered value so the
-                # next update that DOES carry dry_time sees the true previous.
-                raw_dry_time = ams_unit.get("dry_time")
-                if raw_dry_time is None:
-                    continue
-                try:
-                    current = int(raw_dry_time)
-                except (TypeError, ValueError):
-                    continue
-                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,
-                    )
+        # — fire the callback so smart-plug auto-off-after-drying can run,
+        # and drop our cached target-cycle params so the badge stops claiming
+        # an active cycle. Works identically for queue-triggered, ambient,
+        # and manual drying because we observe the firmware-reported state.
+        for ams_unit in merged_ams:
+            try:
+                ams_id = int(ams_unit.get("id", -1))
+            except (TypeError, ValueError):
+                continue
+            if ams_id < 0:
+                continue
+            # Only evaluate the edge when this update carries an explicit
+            # dry_time. An absent / unparseable value is NOT zero — treating
+            # it as 0 lets a tray-only partial fake a drying-complete edge
+            # (#1462). Skip without touching the remembered value so the
+            # next update that DOES carry dry_time sees the true previous.
+            raw_dry_time = ams_unit.get("dry_time")
+            if raw_dry_time is None:
+                continue
+            try:
+                current = int(raw_dry_time)
+            except (TypeError, ValueError):
+                continue
+            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._drying_targets.pop(ams_id, None)
+                if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
 
         # Create a hash of relevant AMS data to detect changes
@@ -2084,7 +2178,14 @@ class BambuMQTTClient:
             if new_layer > old_layer and self.on_layer_change:
                 self.on_layer_change(new_layer)
         if "total_layer_num" in data:
-            self.state.total_layers = int(data["total_layer_num"])
+            # Some firmware (P1S observed) resets `total_layer_num` to 0 at
+            # print end — same shape as the `layer_num` reset guarded above.
+            # Preserve the last known good value so the usage-tracker split
+            # path (#1771) has a denominator that survives the reset frame.
+            # Explicit reset to 0 happens on print start (`_handle_print_start`).
+            new_total = int(data["total_layer_num"])
+            if new_total > 0:
+                self.state.total_layers = new_total
 
         # Fan speeds (MQTT sends as string "0"-"15" representing speed levels, or percentage)
         # Convert to 0-100 percentage for display
@@ -2646,12 +2747,24 @@ class BambuMQTTClient:
                         short_code = f"{(attr >> 16) & 0xFFFF:04X}_{code & 0xFFFF:04X}"
                         if short_code in _HMS_USER_ACTION_CODES:
                             continue
+                        # Catalog has both 8-char keys (base class) and 16-char keys
+                        # (specific variants). The full 16-char identifier preserves
+                        # the 32 bits of `attr_low` + `code_high` that the short_code
+                        # discards — that's the firmware's matching key, so try it
+                        # first and fall back to the short form.
+                        full_code = f"{attr:08X}{code:08X}"
+                        actions = get_actions_for_error_code(self.serial_number[:3], full_code)
+                        if not actions:
+                            actions = get_actions_for_error_code(self.serial_number[:3], short_code.replace("_", ""))
                         self.state.hms_errors.append(
                             HMSError(
                                 code=f"0x{code:x}" if code else "0x0",
                                 attr=attr,
                                 module=module,
                                 severity=severity if severity > 0 else 2,
+                                actions=actions,
+                                job_id=self.state.subtask_id,
+                                full_code=full_code,
                             )
                         )
 
@@ -2697,12 +2810,35 @@ class BambuMQTTClient:
                             existing_short_codes.add(f"{e_module:04X}_{e_error:04X}")
 
                         if short_code not in existing_short_codes:
+                            # Bambu's HMS catalog keys by 3-letter device code (the SN
+                            # prefix) and a 16-char short error code without the
+                            # underscore separator we store internally.
+                            actions = get_actions_for_error_code(self.serial_number[:3], short_code.replace("_", ""))
+                            # Bambu pushes the current job as `subtask_id` on the
+                            # state stream; the HMS-action commands echo it back as
+                            # `job_id`. The error payload itself doesn't carry the
+                            # id, so snapshot it from the live state at parse time
+                            # and freeze it on the HMSError so subsequent
+                            # job changes don't invalidate the action.
+                            job_id = self.state.subtask_id
+                            logger.debug(
+                                "[%s, %s] HMS available actions: %s (job_id=%s)",
+                                self.serial_number[:3],
+                                short_code.replace("_", ""),
+                                actions,
+                                job_id,
+                            )
                             self.state.hms_errors.append(
                                 HMSError(
                                     code=f"0x{error:x}",
                                     attr=print_error,  # Store full value for display
                                     module=module >> 8,  # High byte of module (e.g., 0x05)
                                     severity=3,  # Warning level for print_error
+                                    actions=actions,
+                                    job_id=job_id,
+                                    # print_error is already 32-bit — `f"{print_error:08X}"`
+                                    # is the firmware's matching key with no truncation.
+                                    full_code=f"{print_error:08X}",
                                 )
                             )
 
@@ -3071,6 +3207,12 @@ class BambuMQTTClient:
             self.state.hms_errors = []
             # Reset layer tracking for new print (needed for layer-based timelapse)
             self.state.layer_num = 0
+            # Reset total_layers so the previous print's value can't bleed into
+            # this print's usage-tracker split before the new push_status arrives
+            # with the slicer's total (#1771 follow-on to the preservation guard
+            # above at line ~2135 — the guard now ignores firmware-reset 0s, so
+            # the explicit reset has to happen here instead).
+            self.state.total_layers = 0
             # Reset completion tracking for new print
             self._was_running = True
             self._completion_triggered = False
@@ -3439,6 +3581,7 @@ class BambuMQTTClient:
         timelapse: bool = False,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
+        nozzle_mapping: str | None = None,
     ):
         """Start a print job on the printer.
 
@@ -3457,6 +3600,13 @@ class BambuMQTTClient:
             use_ams: Use AMS for automatic filament changes
             nozzle_offset_cali: Run nozzle offset calibration before print
                 (dual-nozzle printers only — silently ignored on single-nozzle).
+            nozzle_mapping: Opaque JSON string captured from BambuStudio's
+                project_file for H2C rack-swap (O1C2) (#1780). When non-null
+                AND the printer is dual-nozzle, parsed and injected as the
+                `nozzle_mapping` array on the dispatched project_file so the
+                firmware honours the user's slicer pick instead of falling
+                back to "last matching nozzle" auto-pick. Silently ignored
+                on single-nozzle printers.
         """
         if self._client and self.state.connected:
             # Bambu print command format — matches Bambu Studio's format.
@@ -3614,6 +3764,27 @@ class BambuMQTTClient:
                 command["print"]["ams_mapping"] = flat_ams_mapping
                 command["print"]["ams_mapping2"] = ams_mapping2
 
+            # H2C dual-nozzle-rack slicer-pick preservation (#1780).
+            # `nozzle_mapping` carries per-filament physical nozzle position
+            # IDs (`list[int]`), JSON-string-encoded when it leaves the queue
+            # item; parse here so the wire ships an array, matching
+            # BambuStudio's project_file shape. Gate by `is_dual_nozzle`
+            # defensively — single-nozzle firmwares would ignore the field
+            # but we err on the side of not emitting unrecognised fields. A
+            # parse failure is logged but never blocks the dispatch — the
+            # firmware will fall back to its auto-pick path, which is the
+            # pre-fix behaviour.
+            if is_dual_nozzle and nozzle_mapping:
+                try:
+                    command["print"]["nozzle_mapping"] = json.loads(nozzle_mapping)
+                except json.JSONDecodeError:
+                    logger.warning(
+                        "[%s] Invalid nozzle_mapping JSON on dispatch, omitting from "
+                        "project_file (firmware will auto-pick): %r",
+                        self.serial_number,
+                        nozzle_mapping,
+                    )
+
             logger.info("[%s] Sending print command: %s", self.serial_number, json.dumps(command))
             self._client.publish(self.topic_publish, json.dumps(command), qos=1)
             # Record what we dispatched so /cover can pick the right plate
@@ -3781,9 +3952,19 @@ class BambuMQTTClient:
         # Update local state immediately
         if option_name == "auto_recovery":
             self.state.print_options.auto_recovery_step_loss = enabled
+        elif option_name == "auto_switch_filament":
+            self.state.ams_filament_backup = enabled
 
         return True
 
+    def set_ams_filament_backup(self, enabled: bool) -> bool:
+        """Toggle AMS Filament Backup (a.k.a. auto-switch / auto-refill).
+
+        Mirrors BambuStudio's "AMS Filament Backup" checkbox. Verified payload
+        shape from H2D capture 2026-06-20.
+        """
+        return self._set_print_option("auto_switch_filament", enabled)
+
     def start_calibration(
         self,
         bed_leveling: bool = False,
@@ -3971,6 +4152,15 @@ class BambuMQTTClient:
             self.serial_number,
             wire_json,
         )
+        # Track the active-cycle target so the badge can show "PETG @ 65°C"
+        # while drying. Bambu only echoes dry_time on subsequent pushes.
+        if mode == 1:
+            self._drying_targets[ams_id] = {
+                "filament": filament or "",
+                "temp": int(temp),
+            }
+        else:
+            self._drying_targets.pop(ams_id, None)
         return True
 
     def _handle_kprofile_response(self, data: dict):
@@ -5241,3 +5431,218 @@ class BambuMQTTClient:
         self._client.publish(self.topic_publish, json.dumps(pushall), qos=1)
         logger.info("[%s] Set liveview %s", self.serial_number, "enabled" if enable else "disabled")
         return True
+
+    def execute_hms_action(self, print_error: str, action: str, job_id: str | None = None) -> bool:
+        """Dispatch the user's choice from the HMS-error modal as a printer command.
+
+        Args:
+            print_error: Canonical hex identifier for the fault — 8 chars for the
+                32-bit `print_error` path, 16 chars for the 64-bit `hms[]` path
+                (HMSError.full_code). Carried through unchanged from the route.
+                Only the `idle_ignore` branch puts it on the wire; resume / stop
+                use BambuStudio's plain shape (verified against a live H2D, the
+                `err`-bearing shape is silently rejected by the firmware).
+            action: One of HMSAction's string values.
+            job_id: The `subtask_id` snapshotted onto the HMSError at parse-time.
+                Preserved for symmetry with the catalog but no longer sent —
+                BambuStudio's actual resume/stop commands are plain and the
+                firmware doesn't echo `job_id` back on the response either.
+
+        Returns False when the MQTT client is offline or when `action` is unknown
+        so the route surfaces it as a 4xx rather than a silent no-op.
+        """
+
+        if not self._client or not self.state.connected:
+            logger.warning("[%s] Cannot execute HMS action: not connected", self.serial_number)
+            return False
+
+        # Always re-push the full state after a command so the modal's underlying
+        # status query reflects the new error list (or absence) on the next tick.
+        def publish(payload: dict):
+            self._client.publish(self.topic_publish, json.dumps(payload), qos=1)
+            self._client.publish(
+                self.topic_publish, json.dumps({"pushing": {"command": "pushall", "sequence_id": "0"}}), qos=1
+            )
+
+        def hms_resume():
+            # BambuStudio's actual shape — plain resume, no err / no job_id.
+            # The `err`-bearing shape (`err`, `param: "reserve"`, `job_id`) is
+            # silently rejected by Bambu firmware on print_error- and hms[]-sourced
+            # faults alike; verified by injecting candidate shapes against a live
+            # H2D paused on a wrong-plate HMS. See #1830 §(2).
+            publish(
+                {
+                    "print": {
+                        "command": "resume",
+                        "param": "",
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def hms_stop():
+            # Same as hms_resume — BambuStudio's actual shape is plain. The
+            # `err`-bearing variant is silently rejected; verified on the H2D.
+            publish(
+                {
+                    "print": {
+                        "command": "stop",
+                        "param": "",
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def hms_ignore(persistent: bool = False):
+            # `idle_ignore` is BambuStudio's "dismiss this warning" command for
+            # non-pause warnings. type=0 dismisses once, type=1 hides the same
+            # warning permanently.
+            #
+            # For HMS-paused state, `idle_ignore` is silently rejected by the
+            # firmware regardless of `err` (verified on a live H2D — see
+            # #1830 §(2)). The user-facing intent of "Ignore and resume" on a
+            # paused print is to continue, so we dispatch a plain resume
+            # instead. The `persistent` flag is informational in that branch —
+            # firmware can't honour "don't remind" through a resume — but the
+            # button still does what the user expects.
+            #
+            # NB: PrinterState's `state` field carries the MQTT `gcode_state`
+            # value verbatim — line 2144 stores `data["gcode_state"]` onto it.
+            if self.state.state == "PAUSE":
+                hms_resume()
+                return
+            publish(
+                {
+                    "print": {
+                        "command": "idle_ignore",
+                        "err": print_error,
+                        "type": 1 if persistent else 0,
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def ams_control(param: str):
+            publish(
+                {
+                    "print": {
+                        "command": "ams_control",
+                        "param": param,
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def clean_print_error():
+            # Matches the existing `clear_hms_errors` shape — Bambu does not
+            # expect `print_error` in the body; the command clears whatever
+            # error dialog is currently active on the printer.
+            publish(
+                {
+                    "print": {
+                        "command": "clean_print_error",
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        def uiop_close():
+            # `err` is the 8-char hex short code (already a string from the
+            # frontend), uppercased for consistency with how BambuStudio sends it.
+            publish(
+                {
+                    "system": {
+                        "command": "uiop",
+                        "name": "print_error",
+                        "action": "close",
+                        "source": 1,
+                        "type": "dialog",
+                        "err": print_error.upper(),
+                        "sequence_id": "0",
+                    }
+                }
+            )
+
+        match action:
+            case (
+                HMSAction.RESUME_PRINTING
+                | HMSAction.RESUME_PRINTING_DEFECTS
+                | HMSAction.RESUME_PRINTING_PROBELM_SOLVED
+                | HMSAction.PROBLEM_SOLVED_RESUME
+                | HMSAction.FILAMENT_LOAD_RESUME
+                | HMSAction.PROCEED
+            ):
+                hms_resume()
+
+            case HMSAction.STOP_PRINTING:
+                hms_stop()
+
+            case HMSAction.IGNORE_RESUME | HMSAction.NO_REMINDER_NEXT_TIME:
+                hms_ignore(persistent=False)
+
+            case HMSAction.IGNORE_NO_REMINDER_NEXT_TIME | HMSAction.DONT_REMIND_NEXT_TIME:
+                hms_ignore(persistent=True)
+
+            case HMSAction.FILAMENT_EXTRUDED | HMSAction.DBL_CHECK_DONE:
+                ams_control("done")
+
+            case (
+                HMSAction.RETRY_FILAMENT_EXTRUDED
+                | HMSAction.CONTINUE
+                | HMSAction.RETRY_PROBLEM_SOLVED
+                | HMSAction.DBL_CHECK_RETRY
+            ):
+                ams_control("resume")
+
+            case HMSAction.ABORT:
+                ams_control("abort")
+
+            case HMSAction.OK_BUTTON:
+                clean_print_error()
+
+            case HMSAction.DBL_CHECK_OK:
+                clean_print_error()
+                uiop_close()
+
+            case HMSAction.DBL_CHECK_RESUME:
+                # Plain resume — not HMS-aware, no err/job_id.
+                publish(
+                    {
+                        "print": {
+                            "command": "resume",
+                            "param": "",
+                            "sequence_id": "0",
+                        }
+                    }
+                )
+
+            case HMSAction.REFRESH_NOZZLE:
+                publish({"print": {"command": "refresh_nozzle", "sequence_id": "0"}})
+
+            case HMSAction.TURN_OFF_FIRE_ALARM:
+                publish({"print": {"command": "buzzer_ctrl", "mode": 0, "sequence_id": "0"}})
+
+            case HMSAction.STOP_DRYING:
+                publish({"print": {"command": "auto_stop_ams_dry", "sequence_id": "0"}})
+
+            case HMSAction.DISABLE_PURIFICATION:
+                publish({"print": {"command": "close_air_filt", "sequence_id": "0"}})
+
+            case (
+                HMSAction.CHECK_ASSISTANT
+                | HMSAction.JUMP_TO_LIVEVIEW
+                | HMSAction.OK_JUMP_RACK
+                | HMSAction.REMOVE_CLOSE_BTN
+                | HMSAction.LOAD_VIRTUAL_TRAY
+                | HMSAction.CANCLE
+                | HMSAction.DBL_CHECK_CANCEL
+            ):
+                # UI-only actions — the printer's own screen handles these; the
+                # modal still surfaces them so the user has parity with Studio.
+                pass
+
+            case _:
+                logger.warning("[%s] Unknown HMS action '%s'", self.serial_number, action)
+                return False
+
+        return True

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

@@ -236,6 +236,20 @@ def active_broadcaster_keys() -> list[str]:
     return [k for k, bc in _broadcasters.items() if not bc.stopped]
 
 
+def get_subscriber_count(key: str) -> int:
+    """Return the number of live subscribers attached to ``key``, or 0.
+
+    Used by ``/camera/stop`` to decide whether to force-shutdown the broadcaster
+    or defer to natural cleanup. Other viewers (cam-wall tile, embedded viewer,
+    popup window) all subscribe to the same broadcaster, so a force-shutdown
+    triggered by one leaving viewer would kill the others' streams.
+    """
+    bc = _broadcasters.get(key)
+    if bc is None or bc.stopped:
+        return 0
+    return bc.subscriber_count
+
+
 # ---------------------------------------------------------------------------
 # AsyncGenerator helper — turns a subscriber queue into an async generator
 # that yields MJPEG chunks until the upstream signals it's gone.

+ 281 - 9
backend/app/services/filament_deficit.py

@@ -30,6 +30,7 @@ from __future__ import annotations
 
 import json
 import logging
+from collections import defaultdict
 from dataclasses import dataclass
 from pathlib import Path
 
@@ -148,6 +149,88 @@ async def _warnings_disabled(db: AsyncSession) -> bool:
         return False
 
 
+def _normalize_color_for_id(raw: str | None) -> str:
+    """Canonicalise a hex colour for identity comparison.
+
+    Strips the leading ``#``, uppercases, and drops the alpha channel when
+    the hex is 8 chars long (``RRGGBBAA``) so a fully-opaque 8-char hex
+    matches a 6-char hex of the same RGB. Empty / None → empty string.
+    """
+    s = (raw or "").strip().lstrip("#").upper()
+    if len(s) == 8:  # RRGGBBAA → strip alpha
+        s = s[:6]
+    return s
+
+
+def _material_identity_internal(spool) -> str:
+    """Strict same-material key for backup-peer matching in internal mode.
+
+    Requires a Bambu filament preset ID (``slicer_filament``, e.g. ``GFA00``)
+    AND a matching colour. The preset identifies the filament profile (PETG
+    HF, PLA Basic, etc.) — same hot-end behaviour — but the firmware's
+    switch logic also requires the spool to be the same colour (otherwise
+    every PETG HF spool would back every other PETG HF spool regardless of
+    colour, which would dye prints mid-run). Spools without a preset
+    (user-tagged / non-Bambu) get a per-spool unique key so they NEVER
+    pair with anything else; without the Bambu preset the firmware can't
+    trust the backup decision.
+    """
+    preset = (spool.slicer_filament or "").strip() if spool else ""
+    if preset:
+        color = _normalize_color_for_id(spool.rgba if spool else None)
+        return f"preset:{preset}|color:{color}"
+    # Unique-per-spool key prevents grouping. Use the spool's primary key so
+    # the same spool always resolves to the same key within a request.
+    spool_id = getattr(spool, "id", None) if spool else None
+    return f"unmatched:{spool_id}"
+
+
+def _material_identity_spoolman(spool: dict | None) -> str:
+    """Strict same-material key for backup-peer matching in Spoolman mode.
+
+    Two spools pair only when they reference the same Spoolman ``filament``
+    catalog entry (same ``filament.id``) AND share the same colour. The
+    catalog entry pins the profile (PETG HF / PLA Basic / ...); the colour
+    pins the variant. Spools without a resolvable filament id get a
+    per-spool unique key so they never pair.
+    """
+    if not spool:
+        return "unmatched:none"
+    filament = spool.get("filament") or {}
+    fil_id = filament.get("id")
+    if isinstance(fil_id, (int, str)) and str(fil_id).strip():
+        # Prefer the per-spool override colour when set (Spoolman lets the user
+        # tag a spool with a colour distinct from the filament catalog
+        # default); fall back to the filament catalog colour.
+        color = _normalize_color_for_id(
+            (spool.get("color_hex") if isinstance(spool.get("color_hex"), str) else None) or filament.get("color_hex")
+        )
+        return f"filament:{fil_id}|color:{color}"
+    spool_id = spool.get("id")
+    return f"unmatched:{spool_id}"
+
+
+def _ams_id_from_global(global_tray_id: int) -> int:
+    """Inverse of ``_global_to_ams_key`` returning ams_id only."""
+    return _global_to_ams_key(global_tray_id)[0]
+
+
+def _extruder_side_for_ams(
+    ams_id: int,
+    ams_extruder_map: dict[str, int],
+    is_dual_extruder: bool,
+) -> int:
+    """Resolve the extruder index (0=right, 1=left) for a given AMS unit.
+
+    Single-extruder printers collapse everything to 0. On dual-extruder
+    printers (H2D / H2C / X2D), the firmware can't cross extruders even with
+    AMS Filament Backup ON, so the pool must be scoped per-side.
+    """
+    if not is_dual_extruder:
+        return 0
+    return int(ams_extruder_map.get(str(ams_id), 0))
+
+
 def _parse_ams_mapping(raw: str | None) -> list[int] | None:
     if not raw:
         return None
@@ -160,6 +243,33 @@ def _parse_ams_mapping(raw: str | None) -> list[int] | None:
     return [v for v in parsed if isinstance(v, int)]
 
 
+async def _get_printer_backup_context(
+    printer_id: int,
+) -> tuple[bool, dict[str, int], bool]:
+    """Return ``(backup_on, ams_extruder_map, is_dual_extruder)`` for the printer.
+
+    Read from the live MQTT state via ``printer_manager`` (no DB round-trip).
+    Defaults conservatively to ``backup_on=False`` when the state is missing
+    or the printer is offline — same fallback as today (per-slot deficit
+    accounting), so an offline printer is never treated as backup-capable.
+    """
+    try:
+        from backend.app.services.printer_manager import printer_manager
+        from backend.app.utils.printer_models import is_dual_nozzle_model
+    except ImportError:
+        return False, {}, False
+
+    state = printer_manager.get_status(printer_id)
+    if state is None:
+        return False, {}, False
+
+    backup_on = state.ams_filament_backup is True
+    ams_extruder_map = dict(state.ams_extruder_map or {})
+    model = printer_manager.get_model(printer_id)
+    is_dual = bool(model and is_dual_nozzle_model(model))
+    return backup_on, ams_extruder_map, is_dual
+
+
 async def compute_deficit_for_queue_item(
     db: AsyncSession,
     item: PrintQueueItem,
@@ -178,6 +288,14 @@ async def compute_deficit_for_queue_item(
       before dispatch; until it does we cannot map slot → tray.
     * Spoolman mode is on but the Spoolman server is unreachable. We do not
       wedge the queue on a network blip.
+
+    #1762: when the printer reports ``ams_filament_backup=True`` in MQTT
+    status, available material is pooled across ALL same-material spools on
+    the printer (within the same extruder side for dual-nozzle models, since
+    firmware can't cross extruders even with the backup bit set). Per-slot
+    shortfalls are then only emitted if the POOL is too small for the
+    print's total required of that material — matching how the printer
+    actually behaves with Filament Backup ON.
     """
     if await _warnings_disabled(db):
         return []
@@ -210,8 +328,27 @@ async def compute_deficit_for_queue_item(
         return []
 
     spoolman_mode = await _is_spoolman_mode(db)
+    backup_on, ams_extruder_map, is_dual = await _get_printer_backup_context(item.printer_id)
+
+    # ------------------------------------------------------------------ phase 1
+    # Resolve each requirement to (ams_id, tray_id, identity, remaining_grams).
+    # Slot identity is the identity of the spool *assigned to that slot*. A
+    # ``None`` remaining means "couldn't determine" — treated as "no deficit"
+    # below (preserved from pre-#1762 behaviour for non-backup paths too).
+    @dataclass
+    class _ReqRow:
+        slot_id: int
+        ams_id: int
+        tray_id: int
+        global_tray_id: int
+        required: float
+        identity: str
+        remaining: float | None
+        filament_type: str
+        extruder: int
+
+    resolved: list[_ReqRow] = []
 
-    deficits: list[FilamentDeficit] = []
     for req in requirements:
         slot_id = req.get("slot_id")
         used_grams = req.get("used_grams")
@@ -227,6 +364,7 @@ async def compute_deficit_for_queue_item(
             continue
         ams_id, tray_id = _global_to_ams_key(global_tray_id)
 
+        identity = "attrs:|||"
         remaining: float | None = None
         if spoolman_mode:
             sm_result = await db.execute(
@@ -239,7 +377,32 @@ async def compute_deficit_for_queue_item(
             sm_assignment = sm_result.scalar_one_or_none()
             if sm_assignment is None:
                 continue
-            remaining = await _spoolman_remaining_grams(sm_assignment.spoolman_spool_id)
+            # Live remaining_weight from Spoolman. The fetch also resolves the
+            # filament identity for pooling (material + colour + name).
+            from backend.app.services.spoolman import (
+                SpoolmanClientError,
+                SpoolmanNotFoundError,
+                get_spoolman_client,
+            )
+
+            try:
+                client = await get_spoolman_client()
+                spool_dict = await client.get_spool(sm_assignment.spoolman_spool_id) if client else None
+            except (SpoolmanNotFoundError, SpoolmanClientError):
+                spool_dict = None
+            except Exception as e:
+                logger.debug("Spoolman fetch failed for spool %s: %s", sm_assignment.spoolman_spool_id, e)
+                spool_dict = None
+            if spool_dict:
+                identity = _material_identity_spoolman(spool_dict)
+                rw = spool_dict.get("remaining_weight")
+                if isinstance(rw, (int, float)) and rw >= 0:
+                    remaining = float(rw)
+                else:
+                    used = spool_dict.get("used_weight")
+                    total = (spool_dict.get("filament") or {}).get("weight")
+                    if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+                        remaining = max(0.0, float(total) - float(used))
         else:
             internal_result = await db.execute(
                 select(SpoolAssignment)
@@ -254,6 +417,7 @@ async def compute_deficit_for_queue_item(
             if assignment is None or assignment.spool is None:
                 continue
             spool = assignment.spool
+            identity = _material_identity_internal(spool)
             label_weight = float(spool.label_weight or 0)
             weight_used = float(spool.weight_used or 0)
             if label_weight <= 0:
@@ -261,22 +425,130 @@ async def compute_deficit_for_queue_item(
             remaining = max(0.0, label_weight - weight_used)
 
         if remaining is None:
-            # Spoolman unreachable for this spool — skip rather than block.
-            continue
-        if remaining >= float(used_grams):
+            # Unable to determine remaining grams — preserve pre-#1762 behaviour
+            # (don't block on undetermined data).
             continue
 
-        deficits.append(
-            FilamentDeficit(
+        resolved.append(
+            _ReqRow(
                 slot_id=slot_id,
                 ams_id=ams_id,
                 tray_id=tray_id,
+                global_tray_id=global_tray_id,
+                required=float(used_grams),
+                identity=identity,
+                remaining=remaining,
                 filament_type=str(req.get("type", "")),
-                required_grams=float(used_grams),
-                remaining_grams=remaining,
+                extruder=_extruder_side_for_ams(ams_id, ams_extruder_map, is_dual),
             )
         )
 
+    # ------------------------------------------------------------------ phase 2
+    # When backup is OFF, fall back to today's per-slot accounting (one-line
+    # equivalence of the original loop), so this path is a strict no-op
+    # behaviour-wise vs. the pre-#1762 code.
+    if not backup_on:
+        return [
+            FilamentDeficit(
+                slot_id=row.slot_id,
+                ams_id=row.ams_id,
+                tray_id=row.tray_id,
+                filament_type=row.filament_type,
+                required_grams=row.required,
+                remaining_grams=row.remaining,
+            )
+            for row in resolved
+            if row.remaining is not None and row.remaining < row.required
+        ]
+
+    # ------------------------------------------------------------------ phase 3
+    # Backup ON: build (identity, extruder)-keyed pool and required-sum maps
+    # from EVERY assigned spool on the printer (not just the slots in the
+    # print's mapping). Then emit deficits only when the pool for a slot's
+    # material is too small for the print's total required of that material.
+    pool_by_key: dict[tuple[str, int], float] = defaultdict(float)
+    required_by_key: dict[tuple[str, int], float] = defaultdict(float)
+
+    if spoolman_mode:
+        sm_all = await db.execute(
+            select(SpoolmanSlotAssignment).where(SpoolmanSlotAssignment.printer_id == item.printer_id)
+        )
+        from backend.app.services.spoolman import (
+            SpoolmanClientError,
+            SpoolmanNotFoundError,
+            get_spoolman_client,
+        )
+
+        try:
+            client = await get_spoolman_client()
+        except Exception:
+            client = None
+        for sa in sm_all.scalars().all():
+            if client is None:
+                break
+            try:
+                spool_dict = await client.get_spool(sa.spoolman_spool_id)
+            except (SpoolmanNotFoundError, SpoolmanClientError):
+                continue
+            except Exception as e:
+                logger.debug("Spoolman pool fetch failed for spool %s: %s", sa.spoolman_spool_id, e)
+                continue
+            if not spool_dict:
+                continue
+            identity = _material_identity_spoolman(spool_dict)
+            rw = spool_dict.get("remaining_weight")
+            r: float | None = None
+            if isinstance(rw, (int, float)) and rw >= 0:
+                r = float(rw)
+            else:
+                used = spool_dict.get("used_weight")
+                total = (spool_dict.get("filament") or {}).get("weight")
+                if isinstance(used, (int, float)) and isinstance(total, (int, float)) and total > 0:
+                    r = max(0.0, float(total) - float(used))
+            if r is None:
+                continue
+            extruder = _extruder_side_for_ams(sa.ams_id, ams_extruder_map, is_dual)
+            pool_by_key[(identity, extruder)] += r
+    else:
+        internal_all = await db.execute(
+            select(SpoolAssignment)
+            .options(selectinload(SpoolAssignment.spool))
+            .where(SpoolAssignment.printer_id == item.printer_id)
+        )
+        for assignment in internal_all.scalars().all():
+            spool = assignment.spool
+            if spool is None:
+                continue
+            label_weight = float(spool.label_weight or 0)
+            weight_used = float(spool.weight_used or 0)
+            if label_weight <= 0:
+                continue
+            r = max(0.0, label_weight - weight_used)
+            identity = _material_identity_internal(spool)
+            extruder = _extruder_side_for_ams(assignment.ams_id, ams_extruder_map, is_dual)
+            pool_by_key[(identity, extruder)] += r
+
+    for row in resolved:
+        required_by_key[(row.identity, row.extruder)] += row.required
+
+    deficits: list[FilamentDeficit] = []
+    for row in resolved:
+        key = (row.identity, row.extruder)
+        # Pool insufficient for the print's TOTAL required of this material on
+        # this extruder side → real deficit. The per-slot remaining still gets
+        # surfaced so the UI can point at the slot the user assigned.
+        if pool_by_key[key] < required_by_key[key]:
+            deficits.append(
+                FilamentDeficit(
+                    slot_id=row.slot_id,
+                    ams_id=row.ams_id,
+                    tray_id=row.tray_id,
+                    filament_type=row.filament_type,
+                    required_grams=row.required,
+                    remaining_grams=row.remaining,
+                )
+            )
+
     return deficits
 
 

+ 75 - 0
backend/app/services/hms_actions.py

@@ -0,0 +1,75 @@
+"""HMS action lookup.
+
+Bambu printers report HMS errors with a fixed catalog of remediation actions
+(resume / stop / check assistant / etc.). The catalog is bundled as JSON, keyed
+by the 3-letter SN prefix (printer model code: 03W = A1, 31B = X1C, etc.) and
+the short error code with no separator.
+
+The action IDs and their string names are derived from BambuStudio's source via
+`scripts/update_hms_actions.py`. The data file itself is fetched from Bambu's
+public `e.bambulab.com/hms/GetActionImage.php` endpoint.
+"""
+
+import json
+from enum import StrEnum
+from pathlib import Path
+
+_DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "hms_actions.json"
+
+# Loaded eagerly at import — the file is ~150KB and only read once. Using an
+# absolute path keeps the load independent of CWD (systemd unit, Docker
+# entrypoint, pytest run from `backend/`).
+with _DATA_FILE.open("r", encoding="utf-8") as _f:
+    _actions: dict[str, dict[str, list[str]]] = json.load(_f)
+
+
+class HMSAction(StrEnum):
+    """Remediation actions a Bambu printer can offer for an HMS error.
+
+    Values intentionally match the constants used in BambuStudio's source so the
+    HMS-data fetcher can map Bambu's integer action IDs straight to these
+    strings. The CANCLE typo is preserved verbatim — it's how BambuStudio spells
+    it, and changing it would break the action lookup against the catalog.
+    """
+
+    RESUME_PRINTING = "RESUME_PRINTING"
+    RESUME_PRINTING_DEFECTS = "RESUME_PRINTING_DEFECTS"
+    RESUME_PRINTING_PROBELM_SOLVED = "RESUME_PRINTING_PROBELM_SOLVED"
+    STOP_PRINTING = "STOP_PRINTING"
+    CHECK_ASSISTANT = "CHECK_ASSISTANT"
+    FILAMENT_EXTRUDED = "FILAMENT_EXTRUDED"
+    RETRY_FILAMENT_EXTRUDED = "RETRY_FILAMENT_EXTRUDED"
+    CONTINUE = "CONTINUE"
+    LOAD_VIRTUAL_TRAY = "LOAD_VIRTUAL_TRAY"
+    OK_BUTTON = "OK_BUTTON"
+    FILAMENT_LOAD_RESUME = "FILAMENT_LOAD_RESUME"
+    JUMP_TO_LIVEVIEW = "JUMP_TO_LIVEVIEW"
+    NO_REMINDER_NEXT_TIME = "NO_REMINDER_NEXT_TIME"
+    REFRESH_NOZZLE = "REFRESH_NOZZLE"
+    IGNORE_NO_REMINDER_NEXT_TIME = "IGNORE_NO_REMINDER_NEXT_TIME"
+    IGNORE_RESUME = "IGNORE_RESUME"
+    PROBLEM_SOLVED_RESUME = "PROBLEM_SOLVED_RESUME"
+    TURN_OFF_FIRE_ALARM = "TURN_OFF_FIRE_ALARM"
+    RETRY_PROBLEM_SOLVED = "RETRY_PROBLEM_SOLVED"
+    STOP_DRYING = "STOP_DRYING"
+    CANCLE = "CANCLE"  # sic — verbatim from BambuStudio
+    REMOVE_CLOSE_BTN = "REMOVE_CLOSE_BTN"
+    PROCEED = "PROCEED"
+    OK_JUMP_RACK = "OK_JUMP_RACK"
+    ABORT = "ABORT"
+    DISABLE_PURIFICATION = "DISABLE_PURIFICATION"
+    DONT_REMIND_NEXT_TIME = "DONT_REMIND_NEXT_TIME"
+    DBL_CHECK_CANCEL = "DBL_CHECK_CANCEL"
+    DBL_CHECK_DONE = "DBL_CHECK_DONE"
+    DBL_CHECK_RETRY = "DBL_CHECK_RETRY"
+    DBL_CHECK_RESUME = "DBL_CHECK_RESUME"
+    DBL_CHECK_OK = "DBL_CHECK_OK"
+
+
+def get_actions_for_error_code(device: str, error_code: str) -> list[str]:
+    """Look up the action list for a printer SN prefix + short error code.
+
+    Returns the empty list if the printer model or the error code is unknown —
+    the modal renders no buttons in that case, which is the correct fallback.
+    """
+    return _actions.get(device, {}).get(error_code, [])

+ 354 - 0
backend/app/services/location_service.py

@@ -0,0 +1,354 @@
+"""Storage location catalog — single write path for spool location fields (#1004)."""
+
+from __future__ import annotations
+
+import logging
+import time
+from dataclasses import dataclass
+
+import httpx
+from sqlalchemy import func, select, update
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.location import Location
+from backend.app.models.spool import Spool
+
+logger = logging.getLogger(__name__)
+
+DUPLICATE_LOCATION_NAME = "A location with this name already exists"
+
+
+def normalize_location_name(name: str) -> str:
+    trimmed = name.strip()
+    if not trimmed:
+        raise ValueError("name must not be empty")
+    return trimmed
+
+
+def location_name_key(name: str) -> str:
+    """Case-insensitive lookup key stored on Location.name_key."""
+    return normalize_location_name(name).lower()
+
+
+def assign_location_name(location: Location, name: str) -> None:
+    normalized = normalize_location_name(name)
+    location.name = normalized
+    location.name_key = location_name_key(normalized)
+
+
+@dataclass(frozen=True)
+class SpoolLocationFields:
+    """Canonical spool location state: FK + denormalized string for Spoolman/display."""
+
+    location_id: int | None
+    storage_location: str | None
+
+
+async def get_location_by_id(db: AsyncSession, location_id: int) -> Location | None:
+    result = await db.execute(select(Location).where(Location.id == location_id))
+    return result.scalar_one_or_none()
+
+
+async def get_location_by_name(db: AsyncSession, name: str) -> Location | None:
+    key = location_name_key(name)
+    result = await db.execute(select(Location).where(Location.name_key == key))
+    return result.scalar_one_or_none()
+
+
+async def get_locations_by_name_keys(db: AsyncSession, keys: set[str]) -> dict[str, Location]:
+    if not keys:
+        return {}
+    result = await db.execute(select(Location).where(Location.name_key.in_(keys)))
+    return {loc.name_key: loc for loc in result.scalars().all()}
+
+
+async def _create_location_or_get_existing(db: AsyncSession, normalized: str) -> Location:
+    """Insert a location row, returning the winner on concurrent name_key collision."""
+    existing = await get_location_by_name(db, normalized)
+    if existing:
+        return existing
+    location = Location()
+    assign_location_name(location, normalized)
+    try:
+        async with db.begin_nested():
+            db.add(location)
+            await db.flush()
+        return location
+    except IntegrityError as exc:
+        winner = await get_location_by_name(db, normalized)
+        if winner:
+            return winner
+        raise ValueError(DUPLICATE_LOCATION_NAME) from exc
+
+
+async def _insert_location_if_absent(db: AsyncSession, name: str) -> bool:
+    """Stage a new location row when absent. Returns True when one was added."""
+    normalized = normalize_location_name(name)
+    if await get_location_by_name(db, normalized):
+        return False
+    location = Location()
+    assign_location_name(location, normalized)
+    try:
+        async with db.begin_nested():
+            db.add(location)
+            await db.flush()
+        return True
+    except IntegrityError:
+        # Race: another writer inserted the same name between our check and
+        # flush. The row already exists by definition — surface as "not added"
+        # rather than re-raising. Anything else (NULL constraint, FK, check
+        # constraint) would be a programming bug — re-fetch to verify so we
+        # don't silently drop unrelated IntegrityErrors.
+        if await get_location_by_name(db, normalized):
+            return False
+        logger.warning("IntegrityError on insert of location %r without surviving row", normalized)
+        raise
+
+
+async def resolve_location_by_name(db: AsyncSession, name: str, *, create: bool = True) -> Location | None:
+    """Find a location by name (case-insensitive), optionally creating it."""
+    normalized = normalize_location_name(name)
+    existing = await get_location_by_name(db, normalized)
+    if existing:
+        return existing
+    if not create:
+        return None
+    return await _create_location_or_get_existing(db, normalized)
+
+
+async def resolve_spool_location_fields(
+    db: AsyncSession,
+    *,
+    location_id: int | None = None,
+    storage_location: str | None = None,
+    fields_set: set[str],
+) -> SpoolLocationFields | None:
+    """Resolve location_id + storage_location from API input.
+
+    ``location_id`` wins when both fields appear in ``fields_set``.
+    Returns ``None`` when neither location field was provided.
+    """
+    if "location_id" in fields_set:
+        if location_id is None:
+            return SpoolLocationFields(location_id=None, storage_location=None)
+        loc = await get_location_by_id(db, location_id)
+        if not loc:
+            raise ValueError(f"Location {location_id} not found")
+        return SpoolLocationFields(location_id=loc.id, storage_location=loc.name)
+
+    if "storage_location" in fields_set:
+        if not storage_location:
+            return SpoolLocationFields(location_id=None, storage_location=None)
+        loc = await resolve_location_by_name(db, storage_location)
+        if not loc:
+            return SpoolLocationFields(location_id=None, storage_location=None)
+        return SpoolLocationFields(location_id=loc.id, storage_location=loc.name)
+
+    return None
+
+
+async def prepare_internal_spool_payload(db: AsyncSession, data: dict, fields_set: set[str]) -> dict:
+    """Apply resolved location fields before creating or updating an internal spool."""
+    payload = dict(data)
+    resolved = await resolve_spool_location_fields(
+        db,
+        location_id=payload.get("location_id"),
+        storage_location=payload.get("storage_location"),
+        fields_set=fields_set,
+    )
+    if resolved is not None:
+        payload["location_id"] = resolved.location_id
+        payload["storage_location"] = resolved.storage_location
+    return payload
+
+
+async def resolve_spoolman_location_string(
+    db: AsyncSession,
+    *,
+    location_id: int | None = None,
+    storage_location: str | None = None,
+    fields_set: set[str],
+) -> tuple[str | None, bool]:
+    """Return (Spoolman location string, changed) for proxy writes."""
+    resolved = await resolve_spool_location_fields(
+        db,
+        location_id=location_id,
+        storage_location=storage_location,
+        fields_set=fields_set,
+    )
+    if resolved is None:
+        return None, False
+    return resolved.storage_location, True
+
+
+async def count_internal_spools_at_location(db: AsyncSession, location_id: int) -> int:
+    result = await db.execute(
+        select(func.count())
+        .select_from(Spool)
+        .where(
+            Spool.location_id == location_id,
+            Spool.archived_at.is_(None),
+        )
+    )
+    return int(result.scalar() or 0)
+
+
+async def count_spools_at_location_by_name(db: AsyncSession, name: str) -> int:
+    normalized = name.strip()
+    if not normalized:
+        return 0
+    result = await db.execute(
+        select(func.count())
+        .select_from(Spool)
+        .where(
+            Spool.archived_at.is_(None),
+            func.lower(func.trim(Spool.storage_location)) == normalized.lower(),
+        )
+    )
+    return int(result.scalar() or 0)
+
+
+async def enrich_spool_dicts_with_location_id(db: AsyncSession, spools: list[dict]) -> None:
+    """Attach location_id to mapped Spoolman-style spool dicts in place."""
+    keys = {location_name_key(s["storage_location"]) for s in spools if (s.get("storage_location") or "").strip()}
+    if not keys:
+        for s in spools:
+            s["location_id"] = None
+        return
+
+    by_key = await get_locations_by_name_keys(db, keys)
+    for s in spools:
+        raw = (s.get("storage_location") or "").strip()
+        if not raw:
+            s["location_id"] = None
+            continue
+        loc = by_key.get(location_name_key(raw))
+        s["location_id"] = loc.id if loc else None
+
+
+async def rename_location(db: AsyncSession, location: Location, new_name: str) -> Location:
+    normalized = normalize_location_name(new_name)
+    existing = await get_location_by_name(db, normalized)
+    if existing and existing.id != location.id:
+        raise ValueError(DUPLICATE_LOCATION_NAME)
+
+    old_name = location.name
+    # Mirror the SQL TRIM on the Python side so a legacy row whose
+    # `storage_location` has trailing whitespace still matches against the
+    # `old_name` we just lifted off the Location row. Without `.strip()` the
+    # equality is asymmetric (SQL strips the column; Python doesn't) and
+    # legacy rows quietly fall out of the rename cascade.
+    old_name_key = old_name.strip().lower()
+    assign_location_name(location, normalized)
+    await db.execute(update(Spool).where(Spool.location_id == location.id).values(storage_location=normalized))
+    # Keep legacy rows in sync when only storage_location was set.
+    await db.execute(
+        update(Spool)
+        .where(
+            Spool.location_id.is_(None),
+            func.lower(func.trim(Spool.storage_location)) == old_name_key,
+        )
+        .values(storage_location=normalized, location_id=location.id)
+    )
+    try:
+        await db.flush()
+    except IntegrityError as exc:
+        raise ValueError(DUPLICATE_LOCATION_NAME) from exc
+    return location
+
+
+async def sync_locations_from_spoolman(db: AsyncSession, client) -> bool:
+    """Import distinct Spoolman location strings into the local catalog.
+
+    Returns True when new rows were staged (caller must commit). Logs and
+    returns False on Spoolman fetch failures so the calling read path keeps
+    serving the local catalog instead of 500ing; bare-Exception swallow used
+    to be the shape here and hid both transport errors and shape regressions.
+    """
+    from backend.app.services.spoolman import SpoolmanClientError, SpoolmanUnavailableError
+
+    try:
+        names = await client.get_distinct_locations()
+    except (SpoolmanUnavailableError, SpoolmanClientError, httpx.HTTPError) as exc:
+        logger.warning("location sync from Spoolman failed: %s", exc)
+        return False
+
+    # Collapse case variants before insert — Spoolman may return both
+    # "Drybox 1" and "DRYBOX 1" in the same payload.
+    by_key: dict[str, str] = {}
+    for raw in names:
+        name = (raw or "").strip()
+        if not name:
+            continue
+        key = location_name_key(name)
+        if key not in by_key:
+            by_key[key] = name
+
+    changed = False
+    for name in by_key.values():
+        if await _insert_location_if_absent(db, name):
+            changed = True
+    return changed
+
+
+# Per-URL last-sync timestamp guard. Calling list_spools runs the sync, so on
+# a polling UI without this guard every refetch round-trips to Spoolman and
+# opens a write transaction — measurable latency and SQLite write contention.
+# 60s is long enough to absorb dashboard polling, short enough that a manual
+# spool rename in Spoolman shows up on the next minute's refresh.
+_SPOOLMAN_LOCATION_SYNC_TTL_SECONDS = 60.0
+_spoolman_location_sync_last_run: dict[str, float] = {}
+
+
+def _spoolman_location_sync_cache_clear() -> None:
+    """Test hook: drop the TTL cache so each test starts from a clean slate."""
+    _spoolman_location_sync_last_run.clear()
+
+
+async def maybe_sync_spoolman_locations(db: AsyncSession, *, client=None) -> bool:
+    """Sync Spoolman location names into the local catalog when integration is enabled.
+
+    Pass ``client`` when the caller has already resolved one (the GET /spools
+    route does); otherwise the function falls back to ``init_spoolman_client``.
+    Passing the route's client keeps test fixtures honest — without it, the
+    fall-back path imports from ``backend.app.services.spoolman`` directly and
+    bypasses any patch that targets the route module's alias, which causes
+    real TCP connects to whatever ``spoolman_url`` happens to point at.
+    """
+    from backend.app.api.routes._spoolman_helpers import assert_safe_spoolman_url
+    from backend.app.models.settings import Settings
+
+    result = await db.execute(select(Settings))
+    settings = {s.key: s.value for s in result.scalars().all()}
+    if settings.get("spoolman_enabled", "false").lower() != "true":
+        return False
+    url = settings.get("spoolman_url", "").strip()
+    if not url:
+        return False
+
+    # Debounce: skip the round-trip when we synced this URL recently.
+    cache_key = url.rstrip("/")
+    last_run = _spoolman_location_sync_last_run.get(cache_key, 0.0)
+    now = time.monotonic()
+    if now - last_run < _SPOOLMAN_LOCATION_SYNC_TTL_SECONDS:
+        return False
+
+    try:
+        assert_safe_spoolman_url(url)
+    except ValueError as exc:
+        logger.warning("Spoolman URL rejected by SSRF guard during location sync: %s", exc)
+        return False
+
+    if client is None:
+        from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
+
+        client = await get_spoolman_client()
+        if not client or client.base_url != cache_key:
+            client = await init_spoolman_client(url)
+    if not client:
+        return False
+
+    changed = await sync_locations_from_spoolman(db, client)
+    _spoolman_location_sync_last_run[cache_key] = now
+    return changed

+ 110 - 8
backend/app/services/notification_service.py

@@ -1,11 +1,13 @@
 """Notification service for sending push notifications via various providers."""
 
 import asyncio
+import html
 import json
 import logging
 import re
 import smtplib
 from datetime import datetime, timedelta, timezone
+from email.mime.image import MIMEImage
 from email.mime.multipart import MIMEMultipart
 from email.mime.text import MIMEText
 from typing import Any
@@ -410,8 +412,27 @@ class NotificationService:
         else:
             return False, f"HTTP {response.status_code}: {response.text[:200]}"
 
-    async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:
-        """Send notification via email (SMTP)."""
+    async def _send_email(
+        self,
+        config: dict,
+        subject: str,
+        body: str,
+        image_data: bytes | None = None,
+        finish_photo_url: str | None = None,
+    ) -> tuple[bool, str]:
+        """Send notification via email (SMTP).
+
+        Inline finish-photo embed is opt-in via the template: when the rendered
+        ``body`` contains the substituted ``{finish_photo_url}`` value AND the
+        finish-photo bytes are present, the message is built as
+        ``multipart/related`` wrapping a ``multipart/alternative`` (plain + HTML)
+        plus an inline ``MIMEImage`` with ``Content-ID: <bambuddy-finish-photo>``.
+        The HTML part replaces the URL with ``<img src="cid:...">``; the plain-
+        text part keeps the URL as a clickable link. When the template doesn't
+        reference ``{finish_photo_url}`` (or image bytes aren't available), the
+        original single-part text shape is used — no attachment, no surprise
+        inline image (#1792).
+        """
         smtp_server = config.get("smtp_server", "").strip()
         smtp_port = int(config.get("smtp_port", 587))
         username = config.get("username", "").strip()
@@ -429,12 +450,48 @@ class NotificationService:
         if auth_enabled and not all([username, password]):
             return False, "Username and password are required when authentication is enabled"
 
+        # Template-driven: only inline-embed when the user's template explicitly
+        # referenced {finish_photo_url} (so the URL appears in the rendered body)
+        # AND the photo bytes are available. Falls back to text-only otherwise.
+        inline_photo = bool(image_data and finish_photo_url and finish_photo_url in body)
+
         try:
-            msg = MIMEMultipart()
-            msg["From"] = from_email
-            msg["To"] = to_email
-            msg["Subject"] = f"[Bambuddy] {subject}"
-            msg.attach(MIMEText(body, "plain"))
+            if inline_photo:
+                # multipart/related → (multipart/alternative → text, html) + inline image
+                msg = MIMEMultipart("related")
+                msg["From"] = from_email
+                msg["To"] = to_email
+                msg["Subject"] = f"[Bambuddy] {subject}"
+
+                alt = MIMEMultipart("alternative")
+                alt.attach(MIMEText(body, "plain"))
+                # Build HTML body: escape the rendered body, then swap the
+                # escaped URL substring for an inline <img> referencing the
+                # MIMEImage we attach below. Done AFTER escape so the cid: URL
+                # we inject isn't re-escaped.
+                escaped_body = html.escape(body).replace("\n", "<br>\n")
+                escaped_url = html.escape(finish_photo_url)
+                img_tag = (
+                    '<img src="cid:bambuddy-finish-photo" '
+                    'alt="Printer camera snapshot" '
+                    'style="max-width:100%;height:auto;border:1px solid #ddd;border-radius:4px;">'
+                )
+                html_body = f"<html><body><p>{escaped_body.replace(escaped_url, img_tag)}</p></body></html>"
+                alt.attach(MIMEText(html_body, "html"))
+                msg.attach(alt)
+
+                img = MIMEImage(image_data, _subtype="jpeg")
+                # Angle-bracketed Content-ID per RFC 2392, referenced from HTML
+                # without the brackets via ``cid:bambuddy-finish-photo``.
+                img.add_header("Content-ID", "<bambuddy-finish-photo>")
+                img.add_header("Content-Disposition", "inline", filename="finish-photo.jpg")
+                msg.attach(img)
+            else:
+                msg = MIMEMultipart()
+                msg["From"] = from_email
+                msg["To"] = to_email
+                msg["Subject"] = f"[Bambuddy] {subject}"
+                msg.attach(MIMEText(body, "plain"))
 
             if security == "ssl":
                 # Direct SSL connection (typically port 465)
@@ -682,7 +739,13 @@ class NotificationService:
             elif provider.provider_type == "telegram":
                 return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
             elif provider.provider_type == "email":
-                return await self._send_email(config, title, message)
+                # finish_photo_url is pulled from the rendered template variables
+                # so _send_email can detect whether the template referenced the
+                # URL and inline-embed the photo only in that case.
+                finish_photo_url = (variables or {}).get("finish_photo_url")
+                return await self._send_email(
+                    config, title, message, image_data=image_data, finish_photo_url=finish_photo_url
+                )
             elif provider.provider_type == "discord":
                 return await self._send_discord(config, title, message, image_data=image_data)
             elif provider.provider_type == "webhook":
@@ -1145,6 +1208,45 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_ai_failure_detection(
+        self,
+        printer_id: int,
+        printer_name: str,
+        task_name: str,
+        confidence: float,
+        action: str,
+        db: AsyncSession,
+        image_data: bytes | None = None,
+    ):
+        """Handle AI failure-detection event (Obico spaghetti / print-failure ML).
+
+        Split out of on_printer_error (#1794) so a user can subscribe to AI
+        alerts without also being paged for every HMS hardware code.
+        """
+        providers = await self._get_providers_for_event(db, "on_ai_failure_detection", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "task_name": task_name or "current job",
+            "confidence": f"{confidence:.2f}",
+            "action": action,
+        }
+
+        title, message = await self._build_message_from_template(db, "ai_failure_detection", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ai_failure_detection",
+            printer_id,
+            printer_name,
+            image_data=image_data,
+            variables=variables,
+        )
+
     async def on_plate_not_empty(
         self,
         printer_id: int,

+ 10 - 7
backend/app/services/obico_actions.py

@@ -64,20 +64,23 @@ async def _turn_off_linked_plugs(printer_id: int) -> None:
 
 
 async def _notify(printer_id: int, printer_name: str, task_name: str, score: float, action: str) -> None:
+    """Fire the AI Failure Detection notification (#1794).
+
+    Routed to its own event in 0.2.5b1; previously rode the multiplexed
+    on_printer_error toggle, which made it indistinguishable from HMS
+    hardware errors in the UI.
+    """
     from backend.app.services.notification_service import notification_service
 
-    detail = (
-        f"Possible print failure detected on '{task_name or 'current job'}' "
-        f"(confidence {score:.2f}). Action taken: {action}."
-    )
     async with async_session() as db:
         try:
-            await notification_service.on_printer_error(
+            await notification_service.on_ai_failure_detection(
                 printer_id=printer_id,
                 printer_name=printer_name,
-                error_type="ai_failure_detection",
+                task_name=task_name,
+                confidence=score,
+                action=action,
                 db=db,
-                error_detail=detail,
             )
         except Exception as e:
             logger.error("Obico notify failed for printer %s: %s", printer_id, e)

+ 231 - 0
backend/app/services/plate_thumbnail.py

@@ -0,0 +1,231 @@
+"""Plate thumbnail injection for sliced 3MFs.
+
+When the slicer CLI (Bambu Studio or OrcaSlicer in the docker sidecar)
+produces a ``.gcode.3mf`` without ``Metadata/plate_N.png``, the archive
+card has nothing to show. Both CLIs skip the plate-thumbnail render when
+invoked with ``--slice --export-3mf`` headlessly — that render is a
+GUI-side action that only fires in the desktop Studio. The
+``--export-png`` flag exists but is mutually exclusive with
+``--export-3mf`` and additionally needs a Wayland compositor in the
+container, so we can't reach it from the sidecar's current invocation
+shape.
+
+This module fills the gap server-side: it parses the sliced 3MF, and
+for every ``plate_N.gcode`` entry that doesn't have a matching
+``plate_N.png`` it renders one from the embedded 3D model using the
+same trimesh + matplotlib path as :mod:`backend.app.services.stl_thumbnail`,
+then injects ``Metadata/plate_N.png`` (512x512) + ``Metadata/plate_N_small.png``
+(128x128) into the zip. Best-effort: any failure (no model file,
+trimesh can't parse, matplotlib render fails) returns the input bytes
+unchanged so the slice flow itself never breaks.
+"""
+
+from __future__ import annotations
+
+import io
+import logging
+import re
+import zipfile
+
+logger = logging.getLogger(__name__)
+
+
+# Bambu Studio's plate covers. Match the dimensions BS uses on desktop so
+# the rendered images flow through the same archive UI code paths without
+# special-casing.
+_PLATE_PNG_SIZE = 512
+_PLATE_PNG_SMALL_SIZE = 128
+
+# Mirror stl_thumbnail.py's palette so archive cards rendered through
+# this path are visually consistent with the rest of Bambuddy's library
+# thumbnails — same Bambu green on the same dark background.
+_BAMBU_GREEN = "#00AE42"
+_BACKGROUND_COLOR = "#1a1a1a"
+
+# Above this vertex count, trimesh.simplify_quadric_decimation runs first.
+# Same cap stl_thumbnail.py uses; matplotlib's Poly3DCollection slows down
+# nonlinearly past ~100k faces and a plate thumbnail doesn't need detail
+# beyond what a 512x512 PNG can resolve.
+_MAX_VERTICES = 100_000
+
+# Plate-gcode entries look like ``Metadata/plate_1.gcode``,
+# ``Metadata/plate_12.gcode`` — anything else is a md5 / json sidecar.
+_PLATE_GCODE_RE = re.compile(r"^Metadata/plate_(\d+)\.gcode$")
+
+
+def inject_plate_thumbnails_if_missing(threemf_bytes: bytes) -> bytes:
+    """Return ``threemf_bytes`` with ``plate_N.png`` injected for every
+    plate that's missing one.
+
+    No-op fast path when every plate already has a thumbnail — the input
+    bytes are returned verbatim (same object identity), so the common
+    case of a desktop-Studio-sliced 3MF flowing through this function
+    is essentially free.
+
+    On any failure the input bytes are returned unchanged. A missing
+    thumbnail is a visual degradation; failing the slice would be worse.
+    """
+    try:
+        with zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as zf:
+            names = set(zf.namelist())
+            missing = _missing_plate_ids(names)
+            if not missing:
+                return threemf_bytes
+            if "3D/3dmodel.model" not in names:
+                logger.debug(
+                    "plate_thumbnail: sliced 3MF has no 3D/3dmodel.model — skipping (plates %s)",
+                    sorted(missing),
+                )
+                return threemf_bytes
+    except (zipfile.BadZipFile, OSError) as exc:
+        logger.warning("plate_thumbnail: input is not a readable zip: %s", exc)
+        return threemf_bytes
+
+    try:
+        large_png, small_png = _render_model_thumbnails(threemf_bytes)
+    except Exception as exc:
+        logger.warning(
+            "plate_thumbnail: render failed, returning sliced 3MF without injected thumbs: %s",
+            exc,
+            exc_info=True,
+        )
+        return threemf_bytes
+
+    if large_png is None or small_png is None:
+        return threemf_bytes
+
+    try:
+        return _inject_pngs(threemf_bytes, missing, large_png, small_png)
+    except (zipfile.BadZipFile, OSError) as exc:
+        logger.warning("plate_thumbnail: zip re-pack failed: %s", exc)
+        return threemf_bytes
+
+
+def _missing_plate_ids(names: set[str]) -> list[int]:
+    """Plate IDs that have a ``plate_N.gcode`` but no ``plate_N.png``.
+
+    Multi-plate slices produce one gcode per plate; we render the model
+    once and reuse it for every missing plate. The visual is identical
+    across plates of the same model, which matches what users see today
+    for desktop-Studio-sliced multi-plate projects — Studio also reuses
+    the model render across plates that share geometry.
+    """
+    plate_ids: list[int] = []
+    for name in names:
+        m = _PLATE_GCODE_RE.match(name)
+        if not m:
+            continue
+        n = int(m.group(1))
+        if f"Metadata/plate_{n}.png" not in names:
+            plate_ids.append(n)
+    return sorted(plate_ids)
+
+
+def _render_model_thumbnails(threemf_bytes: bytes) -> tuple[bytes | None, bytes | None]:
+    """Render an isometric view of the 3MF's model at both plate sizes.
+
+    Returns (large, small) PNG bytes, or (None, None) if the model
+    couldn't be loaded. Mirrors stl_thumbnail.py's style (Bambu green
+    mesh on dark background, ~25deg elev / 45deg azim) so this output
+    blends into Bambuddy's existing library/archive cards.
+    """
+    # Local imports so a `import backend.app.services.plate_thumbnail` from
+    # an environment without matplotlib/trimesh doesn't fail at import time —
+    # the function will simply degrade to no-op via the exception branch.
+    from backend.app.services.stl_thumbnail import _configure_matplotlib_cache
+
+    _configure_matplotlib_cache()
+
+    import matplotlib
+
+    matplotlib.use("Agg")
+    import matplotlib.pyplot as plt
+    import trimesh
+    from mpl_toolkits.mplot3d.art3d import Poly3DCollection
+
+    loaded = trimesh.load(io.BytesIO(threemf_bytes), file_type="3mf", force="mesh")
+    if loaded is None or not hasattr(loaded, "vertices") or len(loaded.vertices) == 0:
+        logger.debug("plate_thumbnail: trimesh produced empty mesh from 3MF")
+        return None, None
+
+    mesh = loaded
+    if len(mesh.vertices) > _MAX_VERTICES:
+        try:
+            keep_ratio = _MAX_VERTICES / len(mesh.vertices)
+            target_reduction = max(0.01, min(0.99, 1.0 - keep_ratio))
+            mesh = mesh.simplify_quadric_decimation(target_reduction)
+        except Exception as exc:
+            logger.debug("plate_thumbnail: mesh simplification failed, using original: %s", exc)
+
+    vertices = mesh.vertices
+    bounds_min = vertices.min(axis=0)
+    bounds_max = vertices.max(axis=0)
+    centered = vertices - (bounds_min + bounds_max) / 2
+    max_extent = (bounds_max - bounds_min).max()
+    scaled = centered / max_extent if max_extent > 0 else centered
+
+    faces = mesh.faces
+    poly3d = [[scaled[v] for v in face] for face in faces]
+
+    large = _render_at_size(poly3d, _PLATE_PNG_SIZE, plt, Poly3DCollection)
+    small = _render_at_size(poly3d, _PLATE_PNG_SMALL_SIZE, plt, Poly3DCollection)
+    return large, small
+
+
+def _render_at_size(poly3d, size: int, plt, Poly3DCollection) -> bytes:
+    """Render the prepared poly3d collection to an in-memory PNG."""
+    fig = plt.figure(figsize=(size / 100, size / 100), dpi=100)
+    fig.patch.set_facecolor(_BACKGROUND_COLOR)
+    ax = fig.add_subplot(111, projection="3d")
+    ax.set_facecolor(_BACKGROUND_COLOR)
+    ax.add_collection3d(
+        Poly3DCollection(
+            poly3d,
+            facecolors=_BAMBU_GREEN,
+            edgecolors=_BAMBU_GREEN,
+            linewidths=0.1,
+            alpha=0.9,
+        )
+    )
+    ax.set_xlim(-0.6, 0.6)
+    ax.set_ylim(-0.6, 0.6)
+    ax.set_zlim(-0.6, 0.6)
+    ax.view_init(elev=25, azim=45)
+    ax.set_axis_off()
+    ax.grid(False)
+    plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
+
+    buf = io.BytesIO()
+    fig.savefig(
+        buf,
+        format="png",
+        facecolor=_BACKGROUND_COLOR,
+        edgecolor="none",
+        bbox_inches="tight",
+        pad_inches=0.05,
+        dpi=100,
+    )
+    plt.close(fig)
+    return buf.getvalue()
+
+
+def _inject_pngs(
+    threemf_bytes: bytes,
+    plate_ids: list[int],
+    large_png: bytes,
+    small_png: bytes,
+) -> bytes:
+    """Copy every entry from the input zip to a new one, then append the
+    plate PNGs. Re-pack rather than mutate-in-place because zipfile doesn't
+    support adding entries to an existing archive read from bytes."""
+    out_buf = io.BytesIO()
+    with (
+        zipfile.ZipFile(io.BytesIO(threemf_bytes), "r") as src,
+        zipfile.ZipFile(out_buf, "w", zipfile.ZIP_DEFLATED) as dst,
+    ):
+        for item in src.infolist():
+            dst.writestr(item, src.read(item.filename))
+        for n in plate_ids:
+            dst.writestr(f"Metadata/plate_{n}.png", large_png)
+            dst.writestr(f"Metadata/plate_{n}_small.png", small_png)
+    return out_buf.getvalue()

+ 268 - 43
backend/app/services/print_scheduler.py

@@ -31,7 +31,11 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.notification_service import notification_service
-from backend.app.services.printer_manager import printer_manager, supports_drying
+from backend.app.services.printer_manager import (
+    printer_manager,
+    supports_drying,
+    supports_drying_while_printing,
+)
 from backend.app.services.smart_plug_manager import smart_plug_manager
 from backend.app.utils.filename import derive_remote_filename
 from backend.app.utils.printer_models import normalize_printer_model
@@ -860,6 +864,15 @@ class PrintScheduler:
         # Check if user prefers lowest remaining filament when multiple spools match
         prefer_lowest = await self._get_bool_setting(db, "prefer_lowest_filament")
 
+        # Gate prefer_lowest on the printer's AMS Filament Backup state (#1766).
+        # Without backup, the printer will not switch to a second spool when the
+        # picked one runs out — so sorting toward the lowest leaves the print
+        # at risk of running dry mid-job. None (unknown / A1 family) preserves
+        # today's behaviour intentionally.
+        if prefer_lowest and status.ams_filament_backup is False:
+            logger.info("[prefer-lowest] skipped (AMS Backup OFF on printer %s)", printer_id)
+            prefer_lowest = False
+
         # When the preference is on, surface Bambuddy's inventory-side
         # remaining for each slot that's bound to a tracked spool, so the
         # sort beats the MQTT-only blind spot (#1508). Skip the lookup
@@ -1232,6 +1245,35 @@ class PrintScheduler:
             # _prefer_lowest_sort_key for the full rationale.
             if prefer_lowest:
                 available.sort(key=lambda f: self._prefer_lowest_sort_key(f, inventory_remain_overrides))
+                # INFO-level decision trace for "Prefer Lowest Filament" #1766.
+                # One line per filament req so a bug report can be diagnosed
+                # without enabling debug logging: shows what the matcher saw
+                # (req shape + sorted candidate trays with their remain values
+                # and any inventory override that was applied). Mirrored by
+                # the picked-match log at the bottom of the loop.
+                logger.info(
+                    "[prefer-lowest] req slot=%s type=%r color=%r tii=%r nozzle=%s; available (sorted lowest-first): %s",
+                    req.get("slot_id"),
+                    req_type,
+                    req_color,
+                    req_tray_info_idx,
+                    req_nozzle_id,
+                    [
+                        {
+                            "gtid": f.get("global_tray_id"),
+                            "type": f.get("type"),
+                            "color": f.get("color"),
+                            "tii": f.get("tray_info_idx"),
+                            "remain": f.get("remain"),
+                            "inv_g": (
+                                inventory_remain_overrides.get(f.get("global_tray_id"))
+                                if inventory_remain_overrides
+                                else None
+                            ),
+                        }
+                        for f in available
+                    ],
+                )
 
             # Check if tray_info_idx is unique among available trays
             if req_tray_info_idx:
@@ -1287,6 +1329,35 @@ class PrintScheduler:
                 comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": match["global_tray_id"]})
             else:
                 comparisons.append({"slot_id": req.get("slot_id", 0), "global_tray_id": -1})
+            if prefer_lowest:
+                # Pair with the "available (sorted)" log above so the reporter
+                # bundle shows BOTH what the matcher saw AND which match bucket
+                # won — fast triage when "Prefer Lowest Filament" picks the
+                # wrong slot (#1766).
+                if match:
+                    bucket = (
+                        "idx"
+                        if idx_match is not None
+                        else "exact_color"
+                        if exact_match is not None
+                        else "similar_color"
+                        if similar_match is not None
+                        else "type_only"
+                    )
+                    logger.info(
+                        "[prefer-lowest] picked gtid=%s via %s for req slot=%s",
+                        match["global_tray_id"],
+                        bucket,
+                        req.get("slot_id"),
+                    )
+                else:
+                    logger.info(
+                        "[prefer-lowest] NO MATCH for req slot=%s (type=%r color=%r tii=%r)",
+                        req.get("slot_id"),
+                        req_type,
+                        req_color,
+                        req_tray_info_idx,
+                    )
 
         # Build mapping array
         if not comparisons:
@@ -1424,6 +1495,57 @@ class PrintScheduler:
                 pass
         return self.DEFAULT_DRYING_PRESETS
 
+    async def _get_humidity_thresholds(self, db: AsyncSession) -> dict[str, int]:
+        """Per-filament humidity thresholds (#1605).
+
+        Returns the user-configured overrides map keyed by normalized filament
+        type (uppercase base, e.g. ``PLA``, ``ASA``) plus a ``default`` key for
+        unknown / unmapped types. Empty / unset → empty dict, in which case
+        callers fall back to ``ams_humidity_fair``.
+        """
+        result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_thresholds"))
+        setting = result.scalar_one_or_none()
+        if not setting or not setting.value:
+            return {}
+        try:
+            data = json.loads(setting.value)
+        except json.JSONDecodeError:
+            return {}
+        if not isinstance(data, dict):
+            return {}
+        out: dict[str, int] = {}
+        for key, value in data.items():
+            try:
+                out[str(key).upper() if key != "default" else "default"] = int(value)
+            except (TypeError, ValueError):
+                continue
+        return out
+
+    @staticmethod
+    def resolve_humidity_threshold(trays: list[dict], thresholds: dict[str, int], fallback: int) -> int:
+        """Resolve the effective humidity threshold for an AMS unit (#1605).
+
+        For mixed filament types loaded into one AMS, returns the most
+        restrictive (lowest) threshold across all loaded tray types — matches
+        the conservative-params strategy already used for drying temp/hours.
+        Empty / unloaded trays contribute no constraint. Unknown types use the
+        ``default`` key, falling through to ``fallback`` (= ``ams_humidity_fair``)
+        when no per-type map is configured at all.
+        """
+        default = thresholds.get("default", fallback)
+        if not thresholds:
+            return fallback
+        candidates: list[int] = []
+        for tray in trays:
+            tray_type = str(tray.get("tray_type") or "").strip()
+            if not tray_type:
+                continue
+            base_type = tray_type.split()[0].upper()
+            candidates.append(thresholds.get(base_type, default))
+        if not candidates:
+            return default
+        return min(candidates)
+
     def _get_conservative_drying_params(
         self, trays: list[dict], module_type: str, presets: dict[str, dict[str, int]]
     ) -> tuple[int, int, str] | None:
@@ -1473,12 +1595,17 @@ class PrintScheduler:
     ):
         """Start drying on idle printers based on humidity.
 
-        Two modes (can both be enabled):
+        Three modes (can all be enabled independently):
         - queue_drying_enabled: Dry between scheduled queue prints
         - ambient_drying_enabled: Dry any idle printer when humidity is high, regardless of queue
+        - print_drying_enabled: Also evaluate printers that are currently printing,
+          when model+firmware supports "Print While Drying" (gated by
+          supports_drying_while_printing). Drying temperature is capped at
+          max(40, preset_temp - 5) to protect spools mid-print.
         """
         queue_drying_enabled = await self._get_bool_setting(db, "queue_drying_enabled")
         ambient_drying_enabled = await self._get_bool_setting(db, "ambient_drying_enabled")
+        print_drying_enabled = await self._get_bool_setting(db, "print_drying_enabled")
         if not queue_drying_enabled and not ambient_drying_enabled:
             # Stop active drying on all printers if both features disabled
             if self._drying_in_progress:
@@ -1500,16 +1627,22 @@ class PrintScheduler:
                     printers_with_scheduled.add(item.printer_id)
 
         # If only queue mode is on and no printers have scheduled items, stop drying
-        if not ambient_drying_enabled and not printers_with_scheduled:
+        # (but skip this short-circuit when print_drying_enabled is on — busy printers
+        # may still be eligible for mid-print drying regardless of queue state).
+        if not ambient_drying_enabled and not printers_with_scheduled and not print_drying_enabled:
             for pid in list(self._drying_in_progress):
                 logger.info("Auto-drying: printer %d — stopping, no scheduled prints in queue", pid)
                 await self._stop_drying(pid)
             return
 
-        # Get humidity threshold
+        # Get humidity threshold (global fallback)
         result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
         setting = result.scalar_one_or_none()
-        humidity_threshold = int(setting.value) if setting else 60
+        global_humidity_threshold = int(setting.value) if setting else 60
+
+        # Per-filament humidity threshold overrides (#1605). Empty → fall back
+        # to the global threshold for every AMS unit.
+        per_type_thresholds = await self._get_humidity_thresholds(db)
 
         # Get drying presets
         presets = await self._get_drying_presets(db)
@@ -1521,36 +1654,47 @@ class PrintScheduler:
         all_printers = await db.execute(select(Printer).where(Printer.is_active.is_(True)))
         for printer in all_printers.scalars():
             pid = printer.id
-            if pid in busy_printers:
-                logger.debug("Auto-drying: printer %d skipped — busy", pid)
-                continue
-            # In queue-only mode, only dry printers that have scheduled prints
-            if not ambient_drying_enabled and pid not in printers_with_scheduled:
-                if self._drying_in_progress.get(pid):
-                    logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
-                    await self._stop_drying(pid)
-                logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+
+            # Resolve model+firmware up front — needed to decide whether this printer
+            # qualifies for mid-print drying (busy printer on capable hardware).
+            state = printer_manager.get_status(pid)
+            if not state:
+                logger.debug("Auto-drying: printer %d skipped — no state", pid)
                 continue
-            # When block mode is on, don't START new drying on printers with pending items.
-            # But allow already-drying printers through so humidity auto-stop logic still runs.
-            if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
-                logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+            model = printer_manager.get_model(pid)
+            firmware = state.firmware_version
+
+            mid_print = (
+                pid in busy_printers and print_drying_enabled and supports_drying_while_printing(model, firmware)
+            )
+
+            if pid in busy_printers and not mid_print:
+                logger.debug("Auto-drying: printer %d skipped — busy", pid)
                 continue
+
+            if not mid_print:
+                # In queue-only mode, only dry printers that have scheduled prints
+                if not ambient_drying_enabled and pid not in printers_with_scheduled:
+                    if self._drying_in_progress.get(pid):
+                        logger.info("Auto-drying: printer %d — stopping, no scheduled prints for this printer", pid)
+                        await self._stop_drying(pid)
+                    logger.debug("Auto-drying: printer %d skipped — no scheduled prints", pid)
+                    continue
+                # When block mode is on, don't START new drying on printers with pending items.
+                # But allow already-drying printers through so humidity auto-stop logic still runs.
+                if block_for_drying and pid in printers_with_items and not self._drying_in_progress.get(pid):
+                    logger.debug("Auto-drying: printer %d skipped — has pending items (block mode)", pid)
+                    continue
             if not printer_manager.is_connected(pid):
                 logger.debug("Auto-drying: printer %d skipped — not connected", pid)
                 continue
-            if not self._is_printer_idle(pid, require_plate_clear):
+            if not mid_print and not self._is_printer_idle(pid, require_plate_clear):
                 logger.debug("Auto-drying: printer %d skipped — not idle", pid)
                 continue
 
-            # Check if this printer supports drying
-            state = printer_manager.get_status(pid)
-            if not state:
-                logger.debug("Auto-drying: printer %d skipped — no state", pid)
-                continue
-            model = printer_manager.get_model(pid)
-            firmware = state.firmware_version
-            if not supports_drying(model, firmware):
+            # Check drying capability. For mid-print path, supports_drying_while_printing
+            # was already verified when computing mid_print above.
+            if not mid_print and not supports_drying(model, firmware):
                 logger.debug("Auto-drying: printer %d skipped — model %s does not support drying", pid, model)
                 continue
 
@@ -1565,6 +1709,14 @@ class PrintScheduler:
                     logger.debug("Auto-drying: printer %d AMS %d skipped — module_type=%s", pid, ams_id, module_type)
                     continue
 
+                # Resolve per-filament humidity threshold for this AMS unit (#1605).
+                # Most-restrictive of all loaded tray types; falls back to the
+                # global threshold when no overrides are configured.
+                trays = ams_data.get("tray", []) or []
+                humidity_threshold = self.resolve_humidity_threshold(
+                    trays, per_type_thresholds, global_humidity_threshold
+                )
+
                 dry_time = int(ams_data.get("dry_time") or 0)
 
                 # Read humidity — prefer humidity_raw (actual %) over humidity (index 1-5)
@@ -1634,7 +1786,6 @@ class PrintScheduler:
                     continue
 
                 # Get conservative drying params for mixed filaments
-                trays = ams_data.get("tray", [])
                 params = self._get_conservative_drying_params(trays, module_type, presets)
                 if not params:
                     logger.debug(
@@ -1644,10 +1795,17 @@ class PrintScheduler:
 
                 temp, duration_hours, filament_type = params
 
+                # Mid-print drying: cap drying temperature to protect spools (Bambu warns
+                # "drying temperature must not exceed the filament's softening temperature"
+                # for Print While Drying). Floor at 40 degC — below that the dryer is
+                # ineffective and firmware will reject anyway.
+                if mid_print:
+                    temp = max(40, temp - 5)
+
                 # Start drying
                 logger.info(
                     "Auto-drying: printer %d AMS %d — humidity %d%% > threshold %d%%, "
-                    "starting %s drying at %d°C for %dh",
+                    "starting %s drying at %d°C for %dh%s",
                     pid,
                     ams_id,
                     humidity,
@@ -1655,6 +1813,7 @@ class PrintScheduler:
                     filament_type,
                     temp,
                     duration_hours,
+                    " (mid-print)" if mid_print else "",
                 )
                 success = printer_manager.send_drying_command(
                     pid, ams_id, temp, duration_hours, mode=1, filament=filament_type
@@ -1772,12 +1931,18 @@ class PrintScheduler:
         items — counting it as a failed predecessor was the cascade bug that
         let a single cancellation block 18 items over 3 days for the reporter.
         Only `failed` and `aborted` — real print-attempt failures — block.
+
+        Failures with `gate_acknowledged=True` (set by the per-printer Resume
+        action — #1818) are also excluded from the lookback so the user can
+        clear the gate after fixing the physical issue without having to
+        re-queue every downstream job.
         """
         result = await db.execute(
             select(PrintQueueItem)
             .where(PrintQueueItem.printer_id == item.printer_id)
             .where(PrintQueueItem.id != item.id)
             .where(PrintQueueItem.status.in_(["completed", "failed", "cancelled", "aborted"]))
+            .where(PrintQueueItem.gate_acknowledged == False)  # noqa: E712
             .order_by(PrintQueueItem.completed_at.desc())
             .limit(1)
         )
@@ -1852,6 +2017,14 @@ class PrintScheduler:
         # manual_start) and "scheduler re-blocked" (this method re-flags it
         # on identical spool state) (#1698-followup).
         if item.skip_filament_check:
+            # #1762 diagnostic: surface the short-circuit at INFO so a
+            # future "Print Anyway didn't work" report (e.g. issue #1762
+            # comment 3) has actionable evidence in the support bundle
+            # without needing DEBUG enabled.
+            logger.info(
+                "Queue item %s honouring user's Print Anyway acknowledgement — skipping deficit check",
+                item.id,
+            )
             return False
 
         try:
@@ -1944,6 +2117,7 @@ class PrintScheduler:
         library_file = None
         file_path = None
         filename = None
+        cleanup_disk_paths: list[Path] = []
 
         if item.archive_id:
             # Print from archive
@@ -1979,6 +2153,7 @@ class PrintScheduler:
             filename = library_file.filename
 
             # Create archive from library file so usage tracking has access to the 3MF
+            queue_item_id = item.id
             try:
                 from backend.app.services.archive import ArchiveService
 
@@ -1992,6 +2167,17 @@ class PrintScheduler:
                 )
                 if archive:
                     item.archive_id = archive.id
+                    if item.cleanup_library_after_dispatch and not library_file.is_external:
+                        item.library_file_id = None
+                        cleanup_disk_paths.append(file_path)
+                        if library_file.thumbnail_path:
+                            thumb_path = Path(library_file.thumbnail_path)
+                            if not thumb_path.is_absolute():
+                                thumb_path = settings.base_dir / library_file.thumbnail_path
+                            cleanup_disk_paths.append(thumb_path)
+                        await db.delete(library_file)
+                        file_path = settings.base_dir / archive.file_path
+                        filename = archive.filename
                     await db.flush()
                     logger.info(
                         "Queue item %s: Created archive %s from library file %s",
@@ -2000,7 +2186,30 @@ class PrintScheduler:
                         item.library_file_id,
                     )
             except Exception as e:
-                logger.warning("Queue item %s: Failed to create archive from library file: %s", item.id, e)
+                logger.warning(
+                    "Queue item %s: Failed to create archive from library file: %s",
+                    queue_item_id,
+                    e,
+                    exc_info=True,
+                )
+                await db.rollback()
+                item = await db.get(PrintQueueItem, queue_item_id)
+                if item:
+                    item.status = "failed"
+                    item.error_message = "Failed to create archive from library file"
+                    item.completed_at = datetime.now(timezone.utc)
+                    await db.commit()
+                    await self._power_off_if_needed(db, item)
+                return
+
+            if not archive:
+                item.status = "failed"
+                item.error_message = "Failed to create archive from library file"
+                item.completed_at = datetime.now(timezone.utc)
+                await db.commit()
+                logger.error("Queue item %s: Archive creation from library file returned no archive", item.id)
+                await self._power_off_if_needed(db, item)
+                return
 
         else:
             # Neither archive nor library file specified
@@ -2156,12 +2365,8 @@ class PrintScheduler:
 
         # Propagate the queue item's owner into printer_manager so the
         # print-complete callback can credit the user in the PrintLogEntry
-        # (#1670). The dispatch path in `background_dispatch.py` does the
-        # equivalent for archive/library "Print" flows; the queue path was
-        # missing this hop, which left the print log's User column blank
-        # for any print started from the queue. `created_by_id` is set
-        # either at queue-add time (UI-added items) or when the user
-        # clicks the manual-start button (#1670 fix in print_queue.py).
+        # (#1670). `created_by_id` is set either at queue-add time (UI-added
+        # items) or when the user clicks the manual-start button.
         await self._propagate_owner_to_printer_manager(db, item)
 
         # IMPORTANT: Set status to "printing" BEFORE sending the print command.
@@ -2174,6 +2379,23 @@ class PrintScheduler:
         item.started_at = datetime.now(timezone.utc)
         await db.commit()
 
+        for cleanup_path in cleanup_disk_paths:
+            try:
+                if cleanup_path.exists():
+                    cleanup_path.unlink()
+            except OSError as cleanup_err:
+                logger.warning(
+                    "TRANSIENT_LIBRARY_FILE_ORPHAN %s",
+                    json.dumps(
+                        {
+                            "queue_item_id": item.id,
+                            "path": str(cleanup_path),
+                            "error": str(cleanup_err),
+                        },
+                        sort_keys=True,
+                    ),
+                )
+
         # Clear the awaiting-plate-clear flag now that we're starting a new print
         printer_manager.set_awaiting_plate_clear(item.printer_id, False)
         logger.info("Queue item %s: Status set to 'printing', sending print command...", item.id)
@@ -2195,7 +2417,11 @@ class PrintScheduler:
         # FINISH-state fallback — no need to force a video.
         effective_timelapse = bool(item.timelapse)
 
-        # Start the print with AMS mapping, plate_id and print options
+        # Start the print with AMS mapping, plate_id and print options.
+        # nozzle_mapping rides through verbatim — JSON string captured from
+        # Bambu Studio's project_file on VP intake (#1780); the MQTT layer
+        # parses + injects it only for dual-nozzle models so a null on every
+        # other model is a transparent pass-through.
         started = printer_manager.start_print(
             item.printer_id,
             remote_filename,
@@ -2208,6 +2434,7 @@ class PrintScheduler:
             timelapse=effective_timelapse,
             use_ams=item.use_ams,
             nozzle_offset_cali=item.nozzle_offset_cali,
+            nozzle_mapping=item.nozzle_mapping,
         )
 
         if started:
@@ -2465,13 +2692,11 @@ class PrintScheduler:
         if landed_on_subtask:
             return
 
-        # Phase A timeout path — same #1150 / #887/#936 discriminator as
-        # background_dispatch: if the printer's gcode_file changed since
+        # Phase A timeout path: if the printer's gcode_file changed since
         # pre-dispatch, the project_file command landed and the printer is
-        # parsing — a forced reconnect mid-parse triggers 0500_4003. If
-        # gcode_file is unchanged, the publish was silently swallowed
-        # (#887/#936) and the original force_reconnect recovery is what we
-        # want.
+        # parsing — a forced reconnect mid-parse triggers 0500_4003 (#1150).
+        # If gcode_file is unchanged, the publish was silently swallowed
+        # (#887/#936) and force_reconnect recovery is what we want.
         client = printer_manager.get_client(printer_id)
         current_gcode_file = getattr(last_status, "gcode_file", None) if last_status else None
         publish_landed = current_gcode_file is not None and current_gcode_file != pre_gcode_file

+ 26 - 4
backend/app/services/printer_diagnostic.py

@@ -16,6 +16,7 @@ import socket
 
 from backend.app.models.printer import Printer
 from backend.app.schemas.printer import DiagnosticCheck, PrinterDiagnosticResult
+from backend.app.services.camera import get_camera_port
 from backend.app.services.discovery import is_running_in_docker
 from backend.app.services.printer_manager import printer_manager
 from backend.app.utils.printer_models import has_external_storage
@@ -26,6 +27,7 @@ logger = logging.getLogger(__name__)
 PORT_MQTT = 8883  # MQTT over TLS — control + status. Connection-critical.
 PORT_FTPS = 990  # FTPS — file upload; required to send prints.
 PORT_RTSPS = 322  # RTSPS — camera stream; optional.
+PORT_CHAMBER_IMAGE = 6000  # Chamber image protocol — A1/P1 camera stream; optional.
 
 _PORT_PROBE_TIMEOUT = 3.0
 
@@ -54,6 +56,19 @@ async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT)
         return False
 
 
+def _camera_port_for_printer(printer: Printer | None) -> tuple[int, str]:
+    """Return the model-specific camera diagnostic port and display protocol."""
+    if not printer:
+        return PORT_RTSPS, "RTSPS"
+    model = getattr(printer, "model", None)
+    if not model:
+        return PORT_RTSPS, "RTSPS"
+    camera_port = get_camera_port(model)
+    if camera_port == PORT_CHAMBER_IMAGE:
+        return camera_port, "Chamber Image"
+    return camera_port, "RTSPS"
+
+
 def _detect_docker_network_mode() -> str:
     """Detect Docker network mode.
 
@@ -118,15 +133,22 @@ async def run_connection_diagnostic(
     checks: list[DiagnosticCheck] = []
 
     # --- Port reachability (probed in parallel) ---
-    mqtt_ok, ftps_ok, rtsps_ok = await asyncio.gather(
+    camera_port, camera_protocol = _camera_port_for_printer(printer)
+    mqtt_ok, ftps_ok, camera_ok = await asyncio.gather(
         _check_port(ip_address, PORT_MQTT),
         _check_port(ip_address, PORT_FTPS),
-        _check_port(ip_address, PORT_RTSPS),
+        _check_port(ip_address, camera_port),
     )
-    # MQTT is connection-critical; FTPS/RTSPS only degrade printing/camera.
+    # MQTT is connection-critical; FTPS/camera only degrade printing/camera.
     checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail"))
     checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftps_ok else "warn"))
-    checks.append(DiagnosticCheck(id="port_rtsps", status="pass" if rtsps_ok else "warn"))
+    checks.append(
+        DiagnosticCheck(
+            id="port_rtsps",
+            status="pass" if camera_ok else "warn",
+            params={"port": camera_port, "protocol": camera_protocol},
+        )
+    )
 
     # --- Docker network mode ---
     network_mode: str | None = None

+ 170 - 5
backend/app/services/printer_manager.py

@@ -85,6 +85,43 @@ def supports_chamber_temp(model: str | None) -> bool:
     return model_upper in CHAMBER_TEMP_SUPPORTED_MODELS
 
 
+# Models with an ACTIVE chamber heater (M141 has an effect).
+# Many printers in CHAMBER_TEMP_SUPPORTED_MODELS only have a passive sensor —
+# X1C, X1E, P2S report chamber temperature but cannot actively heat it. Only
+# the models below ship a PTC heater that responds to M141.
+CHAMBER_HEATER_MODELS = frozenset(
+    [
+        # Display names
+        "H2C",
+        "H2D",
+        "H2DPRO",
+        "H2S",
+        "X2D",
+        # Internal codes (from MQTT/SSDP)
+        "O1C",  # H2C
+        "O1C2",  # H2C dual-nozzle variant
+        "O1D",  # H2D
+        "O1E",  # H2D Pro
+        "O2D",  # H2D Pro alternate code
+        "O1S",  # H2S
+        "N6",  # X2D
+    ]
+)
+
+
+def supports_chamber_heater(model: str | None) -> bool:
+    """Check if a printer model has an active chamber heater (responds to M141).
+
+    The chamber temperature SENSOR is more widely deployed than the chamber
+    HEATER — X1C/X1E/P2S report chamber temp but ignore M141. Only H2C, H2D,
+    H2D Pro, H2S, X2D actually heat. Sensor-only models silently swallow the
+    command at the firmware level, so we 400 at the route to surface that.
+    """
+    if not model:
+        return False
+    return model.strip().upper() in CHAMBER_HEATER_MODELS
+
+
 def has_stg_cur_idle_bug(model: str | None) -> bool:
     """Check if a printer model may incorrectly report stg_cur=0 when idle.
 
@@ -155,6 +192,51 @@ def supports_drying(model: str | None, firmware: str | None) -> bool:
     return True
 
 
+# Minimum firmware versions for AMS "Print While Drying" — drying that runs CONCURRENTLY
+# with an active print. Strictly stricter than _DRYING_MIN_FIRMWARE (idle drying). Verified
+# against Bambu wiki release notes — the canonical phrasing on every supported model is
+# "printing while filament is drying" / "Print While Drying". Models absent from the wiki
+# release notes (A1, A1 Mini, P1*, X1 non-C, X1E) are intentionally excluded — the firmware
+# will reject the command in those cases anyway via dry_sf_reason=[0] (TaskOccupied).
+_DRY_WHILE_PRINTING_MIN_FIRMWARE: dict[str, str] = {
+    "H2D": "01.03.00.00",
+    "H2D PRO": "01.02.00.00",
+    "H2DPRO": "01.02.00.00",
+    "O1E": "01.02.00.00",  # H2D Pro SSDP code
+    "O2D": "01.02.00.00",  # H2D Pro alternate code
+    "H2C": "01.02.00.00",
+    "O1C": "01.02.00.00",  # H2C SSDP code
+    "O1C2": "01.02.00.00",  # H2C dual-nozzle SSDP code
+    "H2S": "01.02.00.00",
+    "X2D": "01.01.00.00",
+    "N6": "01.01.00.00",  # X2D internal code
+    "X1C": "01.11.02.00",
+    "BL-P001": "01.11.02.00",  # X1C internal code
+    "P2S": "01.02.00.00",
+    "N7": "01.02.00.00",  # P2S internal code
+    "A2L": "01.01.00.00",
+    "N9": "01.01.00.00",  # A2L internal code
+}
+
+
+def supports_drying_while_printing(model: str | None, firmware: str | None) -> bool:
+    """Check if a printer model+firmware supports running AMS drying CONCURRENTLY
+    with an active print.
+
+    Distinct from supports_drying() — that gates idle drying. This gate is strict:
+    only models explicitly confirmed by Bambu wiki release notes are allowed.
+    On unsupported models the firmware returns dry_sf_reason=[0] (TaskOccupied)
+    while a print is running, so being conservative here costs nothing — the
+    firmware is the ultimate arbiter, this gate just hides UI affordances.
+    """
+    if not model:
+        return False
+    model_upper = model.strip().upper()
+    if model_upper not in _DRY_WHILE_PRINTING_MIN_FIRMWARE:
+        return False
+    return bool(firmware and firmware >= _DRY_WHILE_PRINTING_MIN_FIRMWARE[model_upper])
+
+
 class PrinterInfo:
     """Basic printer info for callbacks."""
 
@@ -265,7 +347,12 @@ class PrinterManager:
 
             await ws_manager.send_printer_status(
                 printer_id,
-                printer_state_to_dict(state, printer_id, self.get_model(printer_id)),
+                printer_state_to_dict(
+                    state,
+                    printer_id,
+                    self.get_model(printer_id),
+                    self.get_drying_targets(printer_id),
+                ),
             )
         except Exception as e:
             logger.warning(
@@ -474,6 +561,18 @@ class PrinterManager:
         """Get the cached model for a printer."""
         return self._models.get(printer_id)
 
+    def get_drying_targets(self, printer_id: int) -> dict[int, dict] | None:
+        """Get cached active drying target params keyed by AMS id.
+
+        Returned dict shape: ``{ams_id: {"filament": str, "temp": int}}``.
+        Returns ``None`` when the printer is not connected. The cache is
+        seeded by ``send_drying_command(mode=1)`` and cleared when drying
+        stops or on the ``dry_time`` falling edge (handled inside
+        ``BambuMQTTClient``).
+        """
+        client = self._clients.get(printer_id)
+        return client._drying_targets if client else None
+
     def get_all_statuses(self) -> dict[int, PrinterState]:
         """Get status of all connected printers (checks for stale connections)."""
         result = {}
@@ -528,8 +627,15 @@ class PrinterManager:
         timelapse: bool = False,
         use_ams: bool = True,
         nozzle_offset_cali: bool = False,
+        nozzle_mapping: str | None = None,
     ) -> bool:
-        """Start a print on a connected printer."""
+        """Start a print on a connected printer.
+
+        ``nozzle_mapping`` is an opaque JSON string captured from BambuStudio's
+        project_file MQTT command (H2C rack-swap slicer pick preservation,
+        #1780). It rides through to the MQTT client untouched; the dispatch
+        builder there parses + injects it only on dual-nozzle models.
+        """
         caller = traceback.extract_stack(limit=3)[0]
         logger.info(
             "PRINT COMMAND: printer=%s, file=%s, caller=%s:%s:%s",
@@ -551,6 +657,7 @@ class PrinterManager:
                 layer_inspect=layer_inspect,
                 use_ams=use_ams,
                 nozzle_offset_cali=nozzle_offset_cali,
+                nozzle_mapping=nozzle_mapping,
             )
         return False
 
@@ -806,13 +913,21 @@ def resolve_plate_id(state) -> int | None:
     return parse_plate_id(state.gcode_file)
 
 
-def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, model: str | None = None) -> dict:
+def printer_state_to_dict(
+    state: PrinterState,
+    printer_id: int | None = None,
+    model: str | None = None,
+    drying_targets: dict[int, dict] | None = None,
+) -> dict:
     """Convert PrinterState to a JSON-serializable dict.
 
     Args:
         state: The printer state to convert
         printer_id: Optional printer ID for generating cover URLs
         model: Optional printer model for filtering unsupported features
+        drying_targets: Optional per-AMS active-cycle params
+            (``{ams_id: {"filament": str, "temp": int}}``) sourced from the
+            BambuMQTTClient cache so the badge can display "PETG @ 65°C".
     """
     # Parse AMS data from raw_data
     ams_units = []
@@ -898,9 +1013,43 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
             # AMS-HT has 1 tray, regular AMS has 4 trays
             is_ams_ht = len(trays) == 1
 
+            # Active-cycle filament + target temperature for the badge.
+            # Bambu does not echo the cycle's chosen filament/temp on the
+            # per-tick AMS push, so prefer the cached target from the last
+            # ``send_drying_command``. When we have no record (drying
+            # started in a previous backend lifetime, or the cache was
+            # never seeded), fall back to the first loaded tray's
+            # tray_type + RFID-recommended drying_temp — the same heuristic
+            # the popover already uses to seed defaults.
+            ams_id_int = int(ams_data.get("id", 0))
+            target = (drying_targets or {}).get(ams_id_int)
+            dry_target_temp: int | None = None
+            dry_filament: str | None = None
+            if target:
+                temp_val = target.get("temp")
+                fil_val = target.get("filament") or ""
+                if temp_val is not None:
+                    try:
+                        dry_target_temp = int(temp_val)
+                    except (TypeError, ValueError):
+                        dry_target_temp = None
+                if fil_val:
+                    dry_filament = str(fil_val)
+            if dry_target_temp is None or not dry_filament:
+                for tray in trays:
+                    if tray.get("tray_type"):
+                        if not dry_filament:
+                            dry_filament = str(tray["tray_type"])
+                        if dry_target_temp is None and tray.get("drying_temp"):
+                            try:
+                                dry_target_temp = int(tray["drying_temp"])
+                            except (TypeError, ValueError):
+                                pass
+                        break
+
             ams_units.append(
                 {
-                    "id": int(ams_data.get("id", 0)),
+                    "id": ams_id_int,
                     "humidity": humidity_value,
                     "temp": ams_data.get("temp"),
                     "is_ams_ht": is_ams_ht,
@@ -916,6 +1065,9 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
                     "dry_sub_status": int(ams_data.get("dry_sub_status") or 0),
                     # Cannot-dry reasons from firmware (e.g. 1=InsufficientPower, 8=NeedPluginPower)
                     "dry_sf_reason": list(ams_data.get("dry_sf_reason") or []),
+                    # Active-cycle filament name + target temperature
+                    "dry_target_temp": dry_target_temp,
+                    "dry_filament": dry_filament,
                     # Module type: "ams", "n3f", "n3s" (from get_version)
                     "module_type": str(ams_data.get("module_type") or ""),
                 }
@@ -985,7 +1137,15 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "total_layers": state.total_layers,
         "temperatures": temperatures,
         "hms_errors": [
-            {"code": e.code, "attr": e.attr, "module": e.module, "severity": e.severity}
+            {
+                "code": e.code,
+                "attr": e.attr,
+                "module": e.module,
+                "severity": e.severity,
+                "actions": e.actions,
+                "job_id": e.job_id,
+                "full_code": e.full_code,
+            }
             for e in (state.hms_errors or [])
         ],
         # AMS data for filament colors
@@ -1001,6 +1161,10 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         "wifi_signal": state.wifi_signal,
         "wired_network": state.wired_network,
         "door_open": state.door_open,
+        # AMS Filament Backup state (auto-switch to second spool). Tri-state:
+        # True / False / None. None = unknown or unsupported (A1 family). UI
+        # uses this to drive the small status icon next to the AMS drying icon.
+        "ams_filament_backup": state.ams_filament_backup,
         # Calibration stage tracking
         "stg_cur": state.stg_cur,
         "stg_cur_name": get_derived_status_name(state, model),
@@ -1035,6 +1199,7 @@ def printer_state_to_dict(state: PrinterState, printer_id: int | None = None, mo
         ],
         # AMS drying support
         "supports_drying": supports_drying(model, state.firmware_version),
+        "supports_drying_while_printing": supports_drying_while_printing(model, state.firmware_version),
         # 1-indexed plate number parsed from gcode_file (e.g. /Metadata/plate_2.gcode).
         # Pushed via WebSocket so the printer card picks up plate transitions within
         # a multi-plate 3MF without waiting for the 30 s REST poll (#881 follow-up).

+ 3 - 4
backend/app/services/slice_dispatch.py

@@ -1,9 +1,8 @@
 """In-memory background dispatcher for slice jobs.
 
-Mirrors the shape of `background_dispatch.py` (the print-upload dispatcher)
-but tailored for slicing: jobs are independent (no printer-busy gating),
-short-lived (typically 5-60s), and the result is a `LibraryFile` or
-`PrintArchive` row rather than a printer-side dispatch.
+Slice jobs are independent (no printer-busy gating), short-lived (typically
+5-60s), and the result is a `LibraryFile` or `PrintArchive` row rather than a
+printer-side dispatch.
 
 The frontend kicks off a slice via `POST /library/files/{id}/slice` or
 `POST /archives/{id}/slice`, gets back `{job_id, status_url}`, then polls

+ 14 - 1
backend/app/services/slicer_filament_resolver.py

@@ -200,6 +200,19 @@ async def resolve_slicer_filament(
         or tray_info_idx.startswith("PFCN")
     ):
         tray_info_idx = ""
-        setting_id = ""
+        # Preserve setting_id when it's still a valid slicer reference
+        # (PFUS / PFCN cloud user/shared preset, or GFS Bambu official
+        # preset). The slicer accepts these as setting_id even though
+        # they're rejected as tray_info_idx; without preservation the
+        # slicer falls back to whatever generic filament the caller's
+        # tray_info_idx fallback produces and shows "Generic <Material>"
+        # instead of the user's actual custom preset (#1815). Material-name
+        # leaks (e.g. setting_id="PETG") are still cleared — those are
+        # never valid slicer references.
+        if not (
+            setting_id
+            and (setting_id.startswith("PFUS") or setting_id.startswith("PFCN") or setting_id.startswith("GFS"))
+        ):
+            setting_id = ""
 
     return (tray_info_idx, setting_id, sub_brand_override)

+ 256 - 0
backend/app/services/sponsor_prompt.py

@@ -0,0 +1,256 @@
+"""Sponsor-prompt trigger evaluator and dismiss handler.
+
+Drives the in-app "support keeps Bambuddy independent" toast. Trigger families
+fire at milestones the user has earned (prints, archives, filament cost,
+anniversary) plus a soft version-update nudge after a major upgrade.
+
+A 14-day cooldown applies across ALL families: if any toast fired in the last
+14 days, no new toast fires. Each individual milestone is shown at most once
+per user (or once per install in auth-disabled mode); version-update is the
+exception — it re-arms every time the running version is newer than the one
+last acknowledged.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.sponsor_toast_state import SponsorToastState
+from backend.app.models.user import User
+
+logger = logging.getLogger(__name__)
+
+COOLDOWN_DAYS = 14
+
+PRINT_MILESTONES = (10, 25, 100, 500, 1000, 2500, 5000)
+COST_MILESTONES = (25, 50, 100, 500, 1000)
+ARCHIVE_MILESTONES = (5, 10, 50, 250, 1000)
+ANNIVERSARY_YEARS = 1
+
+
+@dataclass
+class Trigger:
+    """Evaluated trigger result returned to the frontend."""
+
+    milestone: str  # e.g. "prints-500", "anniversary-1", "version-update"
+    family: str  # "prints" | "cost" | "archives" | "anniversary" | "version-update"
+    threshold: int | None = None
+    payload: dict[str, Any] = field(default_factory=dict)
+
+
+# ---------------------------------------------------------------------------
+# State helpers
+# ---------------------------------------------------------------------------
+
+
+async def _get_or_create_state(db: AsyncSession, user_id: int | None) -> SponsorToastState:
+    """Fetch the state row for this user (or the install-default NULL row).
+
+    Creates the row lazily on first access so the migration doesn't need to
+    seed anything.
+    """
+    if user_id is None:
+        stmt = select(SponsorToastState).where(SponsorToastState.user_id.is_(None))
+    else:
+        stmt = select(SponsorToastState).where(SponsorToastState.user_id == user_id)
+    result = await db.execute(stmt)
+    state = result.scalar_one_or_none()
+    if state is None:
+        state = SponsorToastState(user_id=user_id, milestones_seen="[]")
+        db.add(state)
+        await db.flush()
+    return state
+
+
+def _within_cooldown(state: SponsorToastState) -> bool:
+    if state.last_shown_at is None:
+        return False
+    cutoff = datetime.now(timezone.utc) - timedelta(days=COOLDOWN_DAYS)
+    last = state.last_shown_at
+    if last.tzinfo is None:
+        last = last.replace(tzinfo=timezone.utc)
+    return last >= cutoff
+
+
+def _seen_milestones(state: SponsorToastState) -> set[str]:
+    try:
+        raw = json.loads(state.milestones_seen or "[]")
+        return set(raw) if isinstance(raw, list) else set()
+    except (json.JSONDecodeError, TypeError):
+        logger.warning(
+            "sponsor_toast_state.milestones_seen for user=%s was not valid JSON; resetting",
+            state.user_id,
+        )
+        return set()
+
+
+# ---------------------------------------------------------------------------
+# Per-family checks
+# ---------------------------------------------------------------------------
+
+
+def _user_filter(column, user_id: int | None):
+    return column.is_(None) if user_id is None else column == user_id
+
+
+async def _check_anniversary(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    milestone = f"anniversary-{ANNIVERSARY_YEARS}"
+    if milestone in seen:
+        return None
+    if user_id is None:
+        # Install-anchor = earliest users.created_at (the first admin row).
+        result = await db.execute(select(func.min(User.created_at)))
+        anchor = result.scalar()
+    else:
+        result = await db.execute(select(User.created_at).where(User.id == user_id))
+        anchor = result.scalar()
+    if anchor is None:
+        return None
+    if anchor.tzinfo is None:
+        anchor = anchor.replace(tzinfo=timezone.utc)
+    if datetime.now(timezone.utc) - anchor < timedelta(days=365 * ANNIVERSARY_YEARS):
+        return None
+    return Trigger(milestone=milestone, family="anniversary")
+
+
+async def _check_prints(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    stmt = (
+        select(func.count())
+        .select_from(PrintLogEntry)
+        .where(
+            PrintLogEntry.status == "completed",
+            _user_filter(PrintLogEntry.created_by_id, user_id),
+        )
+    )
+    completed = (await db.execute(stmt)).scalar() or 0
+    # Pick the LARGEST milestone the user has crossed but not yet seen.
+    for threshold in sorted(PRINT_MILESTONES, reverse=True):
+        key = f"prints-{threshold}"
+        if completed >= threshold and key not in seen:
+            return Trigger(
+                milestone=key,
+                family="prints",
+                threshold=threshold,
+                payload={"count": completed},
+            )
+    return None
+
+
+async def _check_archives(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    stmt = select(func.count()).select_from(PrintArchive).where(_user_filter(PrintArchive.created_by_id, user_id))
+    archived = (await db.execute(stmt)).scalar() or 0
+    for threshold in sorted(ARCHIVE_MILESTONES, reverse=True):
+        key = f"archives-{threshold}"
+        if archived >= threshold and key not in seen:
+            return Trigger(
+                milestone=key,
+                family="archives",
+                threshold=threshold,
+                payload={"count": archived},
+            )
+    return None
+
+
+async def _check_cost(
+    db: AsyncSession, user_id: int | None, seen: set[str], _state: SponsorToastState
+) -> Trigger | None:
+    stmt = (
+        select(func.coalesce(func.sum(PrintLogEntry.cost), 0) + func.coalesce(func.sum(PrintLogEntry.energy_cost), 0))
+        .select_from(PrintLogEntry)
+        .where(_user_filter(PrintLogEntry.created_by_id, user_id))
+    )
+    total = float((await db.execute(stmt)).scalar() or 0)
+    for threshold in sorted(COST_MILESTONES, reverse=True):
+        key = f"cost-{threshold}"
+        if total >= threshold and key not in seen:
+            return Trigger(
+                milestone=key,
+                family="cost",
+                threshold=threshold,
+                payload={"total": round(total, 2)},
+            )
+    return None
+
+
+async def _check_version_update(
+    _db: AsyncSession, _user_id: int | None, _seen: set[str], state: SponsorToastState
+) -> Trigger | None:
+    # version-update is NOT in milestones_seen — it has its own state column
+    # so it can re-fire on each major bump.
+    if not APP_VERSION:
+        return None
+    last = state.last_seen_version
+    if last is None:
+        # First-ever read; treat as already-acknowledged so we don't toast
+        # immediately on a brand-new install. Persist current version silently.
+        state.last_seen_version = APP_VERSION
+        return None
+    if last == APP_VERSION:
+        return None
+    return Trigger(
+        milestone="version-update",
+        family="version-update",
+        payload={"from": last, "to": APP_VERSION},
+    )
+
+
+# Priority order: most emotional / earned first; version-update is the soft fallback.
+_CHECKS = (
+    _check_anniversary,
+    _check_prints,
+    _check_archives,
+    _check_cost,
+    _check_version_update,
+)
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+async def evaluate(db: AsyncSession, user_id: int | None) -> Trigger | None:
+    """Return the next eligible sponsor-toast trigger, or None."""
+    state = await _get_or_create_state(db, user_id)
+    if _within_cooldown(state):
+        return None
+    seen = _seen_milestones(state)
+    for check in _CHECKS:
+        trigger = await check(db, user_id, seen, state)
+        if trigger is not None:
+            return trigger
+    # No triggers eligible — still commit any in-progress state changes
+    # (e.g. version-update's first-touch persistence).
+    await db.flush()
+    return None
+
+
+async def dismiss(db: AsyncSession, user_id: int | None, milestone: str) -> None:
+    """Mark a milestone as shown (sets cooldown anchor + records seen)."""
+    state = await _get_or_create_state(db, user_id)
+    if milestone == "version-update":
+        # Re-armable: just update last_seen_version, don't add to seen-list.
+        state.last_seen_version = APP_VERSION
+    else:
+        seen = _seen_milestones(state)
+        if milestone not in seen:
+            seen.add(milestone)
+            state.milestones_seen = json.dumps(sorted(seen))
+    state.last_shown_at = datetime.now(timezone.utc)
+    await db.flush()

+ 93 - 1
backend/app/services/spoolman.py

@@ -544,6 +544,86 @@ class SpoolmanClient:
             params["allow_archived"] = "true"
         return await self._get_with_retry("/spool", params=params or None)
 
+    async def get_distinct_locations(self) -> list[str]:
+        """Return distinct location strings currently assigned to Spoolman spools.
+
+        Spoolman's `/location` endpoint shape varies across versions: older
+        releases return `list[str]`, newer ones return `list[dict]` with a
+        `name` field. Normalize to `list[str]` so callers can iterate without
+        runtime shape checks.
+        """
+        raw = await self._get_with_retry("/location")
+        if not isinstance(raw, list):
+            return []
+        names: list[str] = []
+        for entry in raw:
+            if isinstance(entry, str):
+                names.append(entry)
+            elif isinstance(entry, dict):
+                name = entry.get("name")
+                if isinstance(name, str):
+                    names.append(name)
+        return names
+
+    async def rename_location(self, current_name: str, new_name: str) -> int:
+        """Bulk-rename a location string on all Spoolman spools.
+
+        Tries the bulk `PATCH /location/{name}` endpoint first. Spoolman
+        versions older than ~0.16 don't expose it and respond 404/405 — in
+        that case fall back to iterating every spool currently at
+        ``current_name`` and PATCHing each one's ``location`` field directly.
+        Returns the number of spools renamed (or 0 if the bulk endpoint
+        succeeded without enumerating).
+        """
+        from urllib.parse import quote
+
+        encoded = quote(current_name, safe="")
+        client = await self._get_client()
+        try:
+            response = await client.patch(
+                f"{self.api_url}/location/{encoded}",
+                json={"name": new_name},
+            )
+            response.raise_for_status()
+            return 0
+        except httpx.HTTPStatusError as exc:
+            if exc.response.status_code not in (404, 405):
+                raise
+            logger.info(
+                "Spoolman bulk-rename endpoint unavailable (status %d); falling back to per-spool PATCH",
+                exc.response.status_code,
+            )
+
+        # Per-spool fallback: enumerate every spool currently at the old name
+        # and PATCH each. Keep going on individual failures so a single
+        # already-deleted spool doesn't strand the rest at the old name —
+        # collect errors and re-raise as a single SpoolmanClientError if any
+        # leftover survives.
+        spools = await self.get_all_spools(allow_archived=True)
+        renamed = 0
+        failures: list[str] = []
+        for spool in spools:
+            if (spool.get("location") or "").strip() != current_name:
+                continue
+            try:
+                await self._request_spool(
+                    "PATCH",
+                    spool["id"],
+                    json_body={"location": new_name},
+                    operation="rename-location",
+                )
+                renamed += 1
+            except SpoolmanNotFoundError:
+                continue
+            except Exception as exc:  # noqa: BLE001 — accumulate and re-raise below
+                failures.append(f"spool {spool.get('id')}: {exc}")
+        if failures:
+            raise SpoolmanClientError(
+                f"Spoolman rename fallback failed for {len(failures)} spool(s): {'; '.join(failures[:3])}",
+                status_code=502,
+            )
+        return renamed
+
     async def delete_spool(self, spool_id: int) -> None:
         """Delete a spool from Spoolman."""
         await self._request_spool("DELETE", spool_id, operation="delete")
@@ -1000,6 +1080,7 @@ class SpoolmanClient:
         cached_spools: list[dict] | None = None,
         inventory_remaining: float | None = None,
         spoolman_spool_id_hint: int | None = None,
+        auto_add_unknown_rfid: bool = True,
     ) -> dict | None:
         """Sync one AMS tray to Spoolman; creates the spool on first sight, updates weight otherwise."""
         logger.debug(
@@ -1046,7 +1127,18 @@ class SpoolmanClient:
                     remaining_weight=None if disable_weight_sync else remaining,
                 )
 
-            # Spool not found by tag - auto-create it
+            # Spool not found by tag - auto-create it, unless the user has
+            # opted out of auto-adding unknown RFIDs (settings.auto_add_unknown_rfid).
+            # Caller broadcasts unknown_tag on the resulting None so the UI can
+            # surface a "+ Add to inventory" affordance on the slot.
+            if not auto_add_unknown_rfid:
+                logger.info(
+                    "Auto-add disabled; skipping Spoolman spool create for %s (tag: %s...)",
+                    tray.tray_sub_brands,
+                    spool_tag[:16],
+                )
+                return None
+
             logger.info("Creating new spool in Spoolman for %s (tag: %s...)", tray.tray_sub_brands, spool_tag[:16])
             if self.is_bambu_lab_spool(tray.tray_uuid, tray.tag_uid, tray.tray_info_idx):
                 filament = await self._find_or_create_filament(tray)

+ 297 - 57
backend/app/services/spoolman_tracking.py

@@ -182,6 +182,52 @@ def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     return lookup
 
 
+def _snapshot_tray_remain(raw_data: dict) -> dict[str, dict]:
+    """Capture per-slot ``remain%`` + ``tray_uuid`` at print start so the
+    completion path can compute a remain-delta when 3MF data doesn't cover
+    the slot (or there's no 3MF at all — #1820).
+
+    Returns ``{"<ams_id>-<tray_id>": {"remain": int, "tray_uuid": str}}``.
+    Only slots whose ``remain`` is a valid 0..100 int are included; invalid
+    values mean the AMS hasn't read the spool yet and a delta would be
+    meaningless. Mirrors the gate in
+    ``usage_tracker.on_print_start:309``.
+    """
+    snapshot: dict[str, dict] = {}
+    ams_raw = raw_data.get("ams", [])
+    ams_data = ams_raw.get("ams", []) if isinstance(ams_raw, dict) else ams_raw if isinstance(ams_raw, list) else []
+    for ams_unit in ams_data:
+        if not isinstance(ams_unit, dict):
+            continue
+        ams_id = int(ams_unit.get("id", 0))
+        for tray in ams_unit.get("tray", []):
+            if not isinstance(tray, dict):
+                continue
+            tray_id = int(tray.get("id", 0))
+            remain = tray.get("remain", -1)
+            if isinstance(remain, int) and 0 <= remain <= 100:
+                snapshot[f"{ams_id}-{tray_id}"] = {
+                    "remain": remain,
+                    "tray_uuid": tray.get("tray_uuid", "") or "",
+                }
+    vt_tray_raw = raw_data.get("vt_tray") or []
+    if isinstance(vt_tray_raw, dict):
+        vt_tray_raw = [vt_tray_raw]
+    for vt in vt_tray_raw:
+        if not isinstance(vt, dict):
+            continue
+        vt_id = int(vt.get("id", 254))
+        # 254 → (255, 0), 255 → (255, 1) — matches usage_tracker's encoding.
+        vt_tray_id = vt_id - 254
+        remain = vt.get("remain", -1)
+        if isinstance(remain, int) and 0 <= remain <= 100:
+            snapshot[f"255-{vt_tray_id}"] = {
+                "remain": remain,
+                "tray_uuid": vt.get("tray_uuid", "") or "",
+            }
+    return snapshot
+
+
 async def store_print_data(
     printer_id: int,
     archive_id: int,
@@ -219,38 +265,65 @@ async def store_print_data(
     if not spoolman_enabled or spoolman_enabled.lower() != "true":
         return
 
-    # Get 3MF file path
+    # Get current AMS tray state up front — needed both for the 3MF path's
+    # ams_trays field and for the remain%-delta snapshot (#1820 fallback for
+    # no-3MF "Untitled" prints, mirroring usage_tracker.on_print_start).
+    state = printer_manager.get_status(printer_id)
+    ams_trays: dict[int, dict] = {}
+    tray_remain_start: dict[str, dict] = {}
+    if state and state.raw_data:
+        ams_trays = build_ams_tray_lookup(state.raw_data)
+        tray_remain_start = _snapshot_tray_remain(state.raw_data)
+
+    # Try to read per-slot filament estimates from the 3MF. Two paths can
+    # leave ``filament_usage`` empty: (1) fallback archive (no .gcode.3mf
+    # was downloadable from the printer — "Untitled" prints, see #1820),
+    # (2) 3MF present but slice_info missing per-filament estimates.
+    # Both fall through to the remain%-delta path at completion.
+    filament_usage: list | None = None
+    layer_usage_json: dict | None = None
+    filament_properties: dict | None = None
     full_path = (
         app_settings.base_dir / file_path
     )  # SEC-PATH-OK: file_path is archive.file_path / library_file.file_path — DB-stored, internally generated
-    if not full_path.exists():
-        logger.debug("[SPOOLMAN] 3MF file not found: %s", full_path)
-        return
-
-    # Resolve the queue item once — used both for the plate-scoped 3MF parsing
-    # fallback (#1697: multi-plate file dispatched for one plate must only count
-    # that plate's filament) and for the ams_mapping fallback below.
-    queue_result = await db.execute(
-        select(PrintQueueItem).where(PrintQueueItem.archive_id == archive_id).where(PrintQueueItem.status == "printing")
-    )
-    queue_item = queue_result.scalar_one_or_none()
-    # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
-    # item's plate_id (queue dispatch path).
-    effective_plate_id = plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
-
-    # Extract per-filament usage from 3MF (total usage for the dispatched plate,
-    # or the whole file for direct/library prints with no plate_id).
-    filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id)
-    if not filament_usage:
-        logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
+    threemf_available = bool(file_path) and full_path.exists()
+    queue_item = None
+    if threemf_available:
+        # Resolve the queue item once — used both for the plate-scoped 3MF parsing
+        # fallback (#1697: multi-plate file dispatched for one plate must only count
+        # that plate's filament) and for the ams_mapping fallback below.
+        queue_result = await db.execute(
+            select(PrintQueueItem)
+            .where(PrintQueueItem.archive_id == archive_id)
+            .where(PrintQueueItem.status == "printing")
+        )
+        queue_item = queue_result.scalar_one_or_none()
+        # Caller-supplied plate_id wins (direct-Print path); fall back to the queue
+        # item's plate_id (queue dispatch path).
+        effective_plate_id = (
+            plate_id if plate_id is not None else (queue_item.plate_id if queue_item is not None else None)
+        )
+        filament_usage = extract_filament_usage_from_3mf(full_path, effective_plate_id) or None
+
+        layer_usage = extract_layer_filament_usage_from_3mf(full_path)
+        if layer_usage:
+            # Convert int keys to string for JSON serialization
+            layer_usage_json = {str(k): v for k, v in layer_usage.items()}
+            logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
+
+        filament_properties = extract_filament_properties_from_3mf(full_path)
+    else:
+        # No 3MF on disk — common for "Untitled" prints whose .gcode.3mf
+        # was never on the printer's FTP. Logged at debug since the
+        # fallback path below picks up the slack when remain% is available.
+        logger.debug("[SPOOLMAN] 3MF file not available: %s", full_path)
+
+    # If neither path has anything useful, there's nothing to track.
+    if not filament_usage and not tray_remain_start:
+        if threemf_available:
+            logger.debug("[SPOOLMAN] No filament usage data in 3MF for archive %s", archive_id)
         return
 
-    # Get current AMS tray state
-    state = printer_manager.get_status(printer_id)
-    ams_trays = {}
-    if state and state.raw_data:
-        ams_trays = build_ams_tray_lookup(state.raw_data)
-
     # Prefer the explicit mapping captured from the print command, then fall back
     # to any queue mapping stored for scheduled/reprint jobs.
     slot_to_tray = ams_mapping if ams_mapping is not None else None
@@ -260,17 +333,6 @@ async def store_print_data(
         except json.JSONDecodeError:
             pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
-    # Parse G-code for per-layer filament usage (for accurate partial usage tracking)
-    layer_usage = extract_layer_filament_usage_from_3mf(full_path)
-    layer_usage_json = None
-    if layer_usage:
-        # Convert int keys to string for JSON serialization
-        layer_usage_json = {str(k): v for k, v in layer_usage.items()}
-        logger.debug("[SPOOLMAN] Parsed %s layers from G-code", len(layer_usage))
-
-    # Extract filament properties (density, diameter) for mm -> grams conversion
-    filament_properties = extract_filament_properties_from_3mf(full_path)
-
     # Delete any existing row for this printer/archive (shouldn't exist, but just in case)
     await db.execute(
         delete(ActivePrintSpoolman)
@@ -278,7 +340,8 @@ async def store_print_data(
         .where(ActivePrintSpoolman.archive_id == archive_id)
     )
 
-    # Insert new tracking data
+    # Insert new tracking data. ``filament_usage`` may be None for the
+    # no-3MF case; report_usage falls back to ``tray_remain_start``.
     tracking = ActivePrintSpoolman(
         printer_id=printer_id,
         archive_id=archive_id,
@@ -287,11 +350,18 @@ async def store_print_data(
         slot_to_tray=slot_to_tray,
         layer_usage=layer_usage_json,
         filament_properties=filament_properties,
+        tray_remain_start=tray_remain_start or None,
     )
     db.add(tracking)
     await db.commit()
 
-    logger.info("[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s", printer_id, archive_id)
+    logger.info(
+        "[SPOOLMAN] Stored tracking data for print: printer=%s, archive=%s (3mf=%s, remain_snapshot=%d slot(s))",
+        printer_id,
+        archive_id,
+        "yes" if filament_usage else "no",
+        len(tray_remain_start),
+    )
     logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
     logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
     if slot_to_tray:
@@ -571,6 +641,7 @@ async def _report_partial_usage(
     filament_usage = tracking.filament_usage or []
     ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
     slot_to_tray = tracking.slot_to_tray
+    tray_remain_start = tracking.tray_remain_start or {}
     printer_serial = await _get_printer_serial(printer_id)
 
     client = await _get_spoolman_client_with_fallback()
@@ -578,6 +649,24 @@ async def _report_partial_usage(
         logger.warning("[SPOOLMAN] Not reachable for partial usage reporting")
         return
 
+    # No-3MF aborted print (#1820 mirror of the completion path): nothing in
+    # filament_usage or layer_usage to base partial estimates on, but the
+    # remain%-delta snapshot we captured at start still describes consumption
+    # up to the abort moment. Write it the same way report_usage's fallback
+    # does, then return — there's no 3MF-derived partial to layer on top.
+    # ``state`` was already fetched at the top of the function for current_layer.
+    if not filament_usage and not layer_usage and tray_remain_start:
+        current_lookup = _snapshot_tray_remain(state.raw_data) if state and state.raw_data else {}
+        await _report_remain_delta_for_slots(
+            client,
+            printer_id=printer_id,
+            tray_remain_start=tray_remain_start,
+            current_lookup=current_lookup,
+            handled_global_tray_ids=set(),
+            archive_id=getattr(tracking, "archive_id", -1),
+        )
+        return
+
     # Try to use accurate G-code parsed data
     if layer_usage:
         layer_usage_int = {
@@ -670,8 +759,15 @@ async def _report_partial_usage(
 async def report_usage(printer_id: int, archive_id: int):
     """Report filament usage to Spoolman after print completion.
 
-    Uses per-filament usage data captured at print start to report
-    usage to the correct spools.
+    Two writers, mirroring the internal-inventory split in usage_tracker:
+
+    1. **3MF path (primary)** — per-filament slice estimates captured at
+       print start drive a precise per-slot ``use_spool`` call.
+    2. **AMS remain%-delta (fallback)** — for slots the 3MF path didn't
+       handle (including the no-3MF "Untitled" case from #1820): compute
+       ``start_remain - current_remain``, multiply by the resolved
+       Spoolman filament's reference weight, and write the delta. Mirrors
+       ``usage_tracker.on_print_complete`` Path 2 (line 517).
     """
     async with async_session() as db:
         from backend.app.api.routes.settings import get_setting
@@ -692,14 +788,15 @@ async def report_usage(printer_id: int, archive_id: int):
         filament_usage = tracking.filament_usage or []
         ams_trays = {int(k): v for k, v in (tracking.ams_trays or {}).items()}
         slot_to_tray = tracking.slot_to_tray
+        tray_remain_start = tracking.tray_remain_start or {}
         printer_serial = await _get_printer_serial(printer_id)
 
         # Delete tracking row (we're done with it)
         await db.delete(tracking)
         await db.commit()
 
-        if not filament_usage:
-            logger.debug("[SPOOLMAN] No filament usage data for archive %s", archive_id)
+        if not filament_usage and not tray_remain_start:
+            logger.debug("[SPOOLMAN] No usage data or remain-snapshot for archive %s", archive_id)
             return
 
         # Check if Spoolman is enabled
@@ -712,20 +809,48 @@ async def report_usage(printer_id: int, archive_id: int):
             logger.warning("[SPOOLMAN] Not reachable for usage reporting")
             return
 
-        logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
-
-        usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
         slot_colors: dict[int, str] = {}
-        spools_updated = await _report_spool_usage_for_slots(
-            client,
-            usage_items,
-            ams_trays,
-            slot_to_tray,
-            f"Archive {archive_id}",
-            printer_serial,
-            printer_id=printer_id,
-            slot_colors_out=slot_colors,
-        )
+        handled_global_tray_ids: set[int] = set()
+        spools_updated = 0
+
+        # --- Path 1: 3MF per-slot estimates -----------------------------
+        if filament_usage:
+            logger.info("[SPOOLMAN] Reporting per-filament usage for archive %s", archive_id)
+            usage_items = [(u.get("slot_id", 0), u.get("used_g", 0)) for u in filament_usage]
+            spools_updated = await _report_spool_usage_for_slots(
+                client,
+                usage_items,
+                ams_trays,
+                slot_to_tray,
+                f"Archive {archive_id}",
+                printer_serial,
+                printer_id=printer_id,
+                slot_colors_out=slot_colors,
+            )
+            # Track which physical slots the 3MF path already covered so
+            # Path 2 doesn't double-charge them.
+            for u in filament_usage:
+                slot_id = u.get("slot_id", 0)
+                handled_global_tray_ids.add(_resolve_global_tray_id(slot_id, slot_to_tray, ams_trays))
+
+        # --- Path 2: AMS remain%-delta for slots 3MF didn't cover -------
+        # Triggered for no-3MF "Untitled" prints (#1820) AND for partial
+        # 3MF coverage (slots whose filament_id wasn't in slice_info).
+        if tray_remain_start:
+            from backend.app.services.printer_manager import printer_manager
+
+            current = printer_manager.get_status(printer_id)
+            current_lookup = _snapshot_tray_remain(current.raw_data) if current and current.raw_data else {}
+            fallback_updates = await _report_remain_delta_for_slots(
+                client,
+                printer_id=printer_id,
+                tray_remain_start=tray_remain_start,
+                current_lookup=current_lookup,
+                handled_global_tray_ids=handled_global_tray_ids,
+                archive_id=archive_id,
+                slot_colors_out=slot_colors,
+            )
+            spools_updated += fallback_updates
 
         if spools_updated == 0:
             logger.info("[SPOOLMAN] Archive %s: no spools updated", archive_id)
@@ -738,6 +863,121 @@ async def report_usage(printer_id: int, archive_id: int):
         await _apply_spool_colors_to_archive(db, archive_id, filament_usage, slot_colors)
 
 
+async def _report_remain_delta_for_slots(
+    client,
+    *,
+    printer_id: int,
+    tray_remain_start: dict[str, dict],
+    current_lookup: dict[str, dict],
+    handled_global_tray_ids: set[int],
+    archive_id: int,
+    slot_colors_out: dict[int, str] | None = None,
+) -> int:
+    """AMS remain%-delta path: write ``(start - current) * filament.weight``
+    grams to Spoolman for slots the 3MF path didn't cover.
+
+    Mirrors ``usage_tracker.on_print_complete`` Path 2: per-slot, gated on a
+    valid current ``remain%``, skipped on spool swap (``tray_uuid`` changed),
+    using the resolved spool's filament reference weight rather than MQTT's
+    unreliable ``tray_weight`` (which is the failure mode #1119 documented).
+    """
+    spools_updated = 0
+    for slot_key, start in tray_remain_start.items():
+        try:
+            ams_id_str, tray_id_str = slot_key.split("-", 1)
+            ams_id, tray_id = int(ams_id_str), int(tray_id_str)
+        except (ValueError, AttributeError):
+            continue
+
+        # Skip slots already handled by the 3MF path. Encoding mirrors
+        # build_ams_tray_lookup: VT trays land at 254/255, AMS-HT keeps
+        # its native id (>=128), regular AMS slots are ams_id*4+tray_id.
+        if ams_id == 255:
+            global_tray_id = 254 + tray_id
+        elif ams_id >= 128:
+            global_tray_id = ams_id
+        else:
+            global_tray_id = ams_id * 4 + tray_id
+        if global_tray_id in handled_global_tray_ids:
+            continue
+
+        current = current_lookup.get(slot_key)
+        if not current:
+            logger.debug("[SPOOLMAN] AMS%d-T%d: no current remain%% at completion, skipping fallback", ams_id, tray_id)
+            continue
+
+        # Spool swap mid-print — tray_uuid changed. We don't know how much
+        # of the print went to which spool; skip rather than mis-attribute.
+        start_uuid = (start.get("tray_uuid") or "").lower()
+        cur_uuid = (current.get("tray_uuid") or "").lower()
+        if start_uuid and cur_uuid and start_uuid != cur_uuid:
+            logger.info(
+                "[SPOOLMAN] AMS%d-T%d: spool swapped mid-print (uuid changed), skipping remain-delta", ams_id, tray_id
+            )
+            continue
+
+        delta_pct = start["remain"] - current["remain"]
+        if delta_pct <= 0:
+            continue  # No consumption captured at AMS granularity, or refilled
+
+        spool_id = await _resolve_spool_id_via_slot_assignment(printer_id, ams_id, tray_id)
+        if spool_id is None:
+            logger.debug("[SPOOLMAN] AMS%d-T%d: no Spoolman slot assignment, skipping fallback", ams_id, tray_id)
+            continue
+
+        # Look up the spool's filament reference weight. Use a fresh GET so
+        # we don't depend on a stale cached_spools list. Failure here is
+        # silent-skip rather than fatal — other slots can still be written.
+        try:
+            spool = await client.get_spool(spool_id)
+        except Exception as exc:  # noqa: BLE001
+            logger.debug("[SPOOLMAN] AMS%d-T%d: get_spool(%s) failed: %s", ams_id, tray_id, spool_id, exc)
+            continue
+        filament = spool.get("filament") or {}
+        ref_weight = filament.get("weight")
+        if not ref_weight or ref_weight <= 0:
+            logger.debug(
+                "[SPOOLMAN] AMS%d-T%d: spool %s has no filament.weight, skipping remain-delta",
+                ams_id,
+                tray_id,
+                spool_id,
+            )
+            continue
+
+        grams_used = round((delta_pct / 100.0) * ref_weight, 2)
+        if grams_used <= 0:
+            continue
+        try:
+            await client.use_spool(spool_id, grams_used)
+        except Exception as exc:  # noqa: BLE001
+            logger.warning(
+                "[SPOOLMAN] AMS%d-T%d: use_spool(%s, %.2fg) failed: %s", ams_id, tray_id, spool_id, grams_used, exc
+            )
+            continue
+
+        spools_updated += 1
+        if slot_colors_out is not None:
+            color = filament.get("color_hex")
+            if color:
+                # No 3MF slot_id for this path — use the AMS slot key so the
+                # colour map can still be inspected by callers if needed.
+                # The archive-colour rewrite (#1494) keys on 3MF slot_ids so
+                # remain-delta-only prints intentionally don't participate
+                # in that rewrite (matches usage_tracker's slot_id=None).
+                slot_colors_out[-(global_tray_id + 1)] = color
+        logger.info(
+            "[SPOOLMAN] Archive %s AMS%d-T%d: %.2fg via remain-delta (%d%% of %.0fg) -> spool %s",
+            archive_id,
+            ams_id,
+            tray_id,
+            grams_used,
+            delta_pct,
+            ref_weight,
+            spool_id,
+        )
+    return spools_updated
+
+
 async def _apply_spool_colors_to_archive(
     db,
     archive_id: int,

+ 19 - 6
backend/app/services/usage_tracker.py

@@ -1127,14 +1127,27 @@ async def _track_from_3mf(
                     mm_at_end = get_cumulative_usage_at_layer(split_layer_usage, seg_end_layer).get(filament_id, 0)
                     segment_grams = mm_to_grams(mm_at_end - mm_at_start, diameter, density)
                 else:
-                    # No per-layer data: linear fallback by layer ratio
+                    # No per-layer data: linear fallback by layer ratio (#1771).
+                    # Cascade denominators because firmware on some models (P1S
+                    # observed) resets `total_layer_num` to 0 at print end —
+                    # `last_layer_num` is the print's last-valid layer captured
+                    # mid-print and survives that reset (same shape as the
+                    # `last_progress` fallback at line 1040). Equal-split is the
+                    # last-resort fence: still wrong, but bounded — never dumps
+                    # the entire print onto the last segment, which was the
+                    # original #1771 symptom for the reporter (P1S, AMS Backup
+                    # fed from spool 1 then spool 2, all 260 g credited to
+                    # spool 2 even though spool 1 had given up its 180 g).
                     seg_end_layer = tray_changes[seg_idx + 1][1]
-                    total_layers = state.total_layers if state else 0
-                    if total_layers > 0:
-                        segment_grams = total_weight * (seg_end_layer - seg_start_layer) / total_layers
+                    denom = (state.total_layers if state else 0) or last_layer_num
+                    if denom > 0:
+                        segment_grams = total_weight * (seg_end_layer - seg_start_layer) / denom
                     else:
-                        # Can't compute ratio — assign all to last segment
-                        segment_grams = 0.0
+                        # No layer information available from any source —
+                        # spread evenly across segments. The last segment will
+                        # get the rounding remainder via the `is_last` branch
+                        # above on its own iteration.
+                        segment_grams = total_weight / len(tray_changes)
 
                 sum_previous += segment_grams
                 if segment_grams <= 0:

+ 253 - 9
backend/app/services/virtual_printer/manager.py

@@ -6,6 +6,7 @@ bound to its dedicated IP address, regardless of mode.
 
 import asyncio
 import logging
+import time
 from collections.abc import Callable
 from datetime import datetime, timezone
 from pathlib import Path
@@ -110,6 +111,23 @@ DEFAULT_VIRTUAL_PRINTER_MODEL = "BL-P001"  # X1C
 # the dict fills, so a long-running VP can't leak unbounded state.
 _SLICER_OPTIONS_CACHE_LIMIT = 128
 
+# How long ``_add_to_print_queue`` waits for the slicer's MQTT
+# ``project_file`` after the FTP upload completes (#1780 round 3).
+# Bambu Studio sends FTP first, then MQTT immediately after — but on
+# wireless / loaded setups the MQTT command can land 2+ s after FTP,
+# which used to time the wait out and silently drop ``nozzle_mapping``
+# + the other slicer-driven flags. The bumped window covers the
+# observed worst case in the field; the late-MQTT fallback in
+# ``on_print_command`` covers the rest.
+_SLICER_OPTIONS_WAIT_TIMEOUT = 5.0
+
+# How long ``on_print_command`` will retroactively stamp slicer fields
+# onto a recently-committed queue item when the MQTT print command
+# arrives after ``_SLICER_OPTIONS_WAIT_TIMEOUT`` expired. Covers
+# extra-late MQTT (slow wireless slicer, NIC drop+retry) and the
+# scheduler tick interval before dispatch picks the item up.
+_RECENT_QUEUE_ITEM_TTL = 30.0
+
 
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
@@ -138,6 +156,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
+        gcode_injection: bool = False,
         bind_ip: str = "",
         remote_interface_ip: str = "",
         tailscale_disabled: bool = True,
@@ -160,6 +179,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
+        self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self.tailscale_disabled = tailscale_disabled
@@ -198,6 +218,16 @@ class VirtualPrinterInstance:
         self._slicer_print_options: dict[str, dict] = {}
         self._slicer_print_options_events: dict[str, asyncio.Event] = {}
 
+        # Queue items recently committed by `_add_to_print_queue`, keyed by
+        # FTP filename. Used by `on_print_command` to retroactively stamp the
+        # slicer's nozzle_mapping (and the other slicer-driven flags) onto a
+        # queue item when the MQTT `project_file` arrives after the queue-add
+        # wait timed out — the #1780 round-3 race. Value is
+        # (queue_item_ids, monotonic_committed_at); entries older than
+        # `_RECENT_QUEUE_ITEM_TTL` are evicted opportunistically on each
+        # queue-add.
+        self._recent_queue_items: dict[str, tuple[list[int], float]] = {}
+
         # Per-instance services
         self._proxy: SlicerProxyManager | None = None
         self._ftp: VirtualPrinterFTPServer | None = None
@@ -287,9 +317,10 @@ class VirtualPrinterInstance:
         """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).
+        `flow_cali`, `vibration_cali`, `layer_inspect`, `use_ams`, plus the
+        H2C rack-pick `nozzle_mapping`) 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, #1780).
         Only queue mode consumes the capture; archive / 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
@@ -299,6 +330,16 @@ class VirtualPrinterInstance:
         moment after the synthetic project_file ack — for every non-proxy
         mode — so the slicer's "Downloading" UI releases on the slicer's
         FTP-first-then-MQTT send order.
+
+        ``filename`` is the slicer's ``subtask_name`` (bare model name, no
+        extension) — used verbatim for `_schedule_finish_release` because
+        push_status echoes it back to the slicer as gcode_file / subtask_name.
+        The queue-side stash key is derived from ``data["file"]`` (the FTP
+        filename with extension) so `_add_to_print_queue`'s
+        ``file_path.name`` lookup matches; falls back to ``filename`` when
+        ``data["file"]`` is absent (legacy slicers / non-3MF uploads).
+        Stash/lookup mismatch was the #1780 root cause — every captured field
+        silently fell back to settings defaults on every Bambu Studio "Send".
         """
         logger.info("[VP %s] Print command for: %s", self.name, filename)
         mode = normalize_vp_mode(self.mode)
@@ -306,6 +347,12 @@ class VirtualPrinterInstance:
             self._schedule_finish_release(filename)
         if mode != VP_MODE_QUEUE:
             return
+        # Stash key must match `_add_to_print_queue`'s lookup, which uses
+        # `file_path.name` (FTP filename WITH extension). The slicer's
+        # `subtask_name` (== this method's `filename` arg) is the bare model
+        # name, no extension — using it as the stash key was the #1780 root
+        # cause.
+        stash_key = data.get("file") or filename
         # Drop the oldest stash if the cache is growing — happens when the
         # slicer sends project_file for a filename whose FTP upload was
         # rejected / cancelled / non-3MF, so _add_to_print_queue's pop
@@ -319,10 +366,118 @@ class VirtualPrinterInstance:
                 logger.debug("[VP %s] Evicted stale slicer options for %s", self.name, stale_key)
             except StopIteration:
                 pass
-        self._slicer_print_options[filename] = dict(data)
-        event = self._slicer_print_options_events.get(filename)
+        self._slicer_print_options[stash_key] = dict(data)
+        event = self._slicer_print_options_events.get(stash_key)
         if event:
             event.set()
+            return
+        # No consumer waiting: `_add_to_print_queue` either already gave up
+        # (wait_for timed out) or hasn't started yet (FTP still uploading).
+        # If a queue item was committed within the last
+        # `_RECENT_QUEUE_ITEM_TTL`, the wait timed out and the row holds
+        # settings defaults instead of the slicer's pick — retroactively
+        # stamp the slicer-driven fields so the dispatcher honours the
+        # user's choice. Covers the #1780 round-3 race where Bambu Studio's
+        # MQTT lands just past the bumped wait ceiling.
+        await self._restamp_recent_queue_item(stash_key, data)
+
+    async def _restamp_recent_queue_item(self, stash_key: str, data: dict) -> None:
+        """Patch slicer-driven fields onto a queue item the MQTT command missed.
+
+        ``_add_to_print_queue`` waits up to ``_SLICER_OPTIONS_WAIT_TIMEOUT``
+        for the slicer's MQTT ``project_file`` before committing the queue
+        item. If the MQTT command arrives after that window — observed in
+        the field at ~2.1 s on H2C / wireless setups (#1780 round 3) — the
+        row was already written with settings defaults. This method runs
+        on the late MQTT path: it looks up the most recent queue items
+        committed for this filename and patches in the slicer's
+        ``nozzle_mapping`` + workflow flags, but only while the items are
+        still ``pending`` (scheduler hasn't dispatched them yet).
+        """
+        if not self._session_factory:
+            return
+        entry = self._recent_queue_items.get(stash_key)
+        if entry is None:
+            return
+        queue_item_ids, committed_at = entry
+        if time.monotonic() - committed_at > _RECENT_QUEUE_ITEM_TTL:
+            self._recent_queue_items.pop(stash_key, None)
+            return
+
+        import json
+
+        # Mirror the field set `_add_to_print_queue` reads off slicer_opts.
+        # MQTT uses `bed_leveling` (single L); the column is `bed_levelling`.
+        # `nozzles_info` is intentionally not stamped — column kept for
+        # legacy rows but never written; see PrintQueueItem.nozzles_info.
+        patch: dict = {}
+        for mqtt_field, column in (
+            ("bed_leveling", "bed_levelling"),
+            ("flow_cali", "flow_cali"),
+            ("vibration_cali", "vibration_cali"),
+            ("layer_inspect", "layer_inspect"),
+            ("timelapse", "timelapse"),
+            ("use_ams", "use_ams"),
+        ):
+            if mqtt_field in data:
+                patch[column] = bool(data[mqtt_field])
+
+        raw = data.get("nozzle_mapping")
+        if raw is not None:
+            if isinstance(raw, str):
+                try:
+                    raw = json.loads(raw)
+                except json.JSONDecodeError:
+                    logger.warning(
+                        "[VP %s] Late MQTT nozzle_mapping is unparseable JSON, dropping: %r",
+                        self.name,
+                        raw,
+                    )
+                    raw = None
+            if raw is not None:
+                patch["nozzle_mapping"] = json.dumps(raw)
+
+        if not patch:
+            self._recent_queue_items.pop(stash_key, None)
+            return
+
+        from sqlalchemy import select, update
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        try:
+            async with self._session_factory() as db:
+                # Only stamp items still pending; once the scheduler has
+                # picked the row up we can't safely race the dispatcher.
+                result = await db.execute(
+                    select(PrintQueueItem.id).where(
+                        PrintQueueItem.id.in_(queue_item_ids),
+                        PrintQueueItem.status == "pending",
+                    )
+                )
+                eligible_ids = [row[0] for row in result.all()]
+                if not eligible_ids:
+                    self._recent_queue_items.pop(stash_key, None)
+                    return
+                await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+                await db.commit()
+                logger.info(
+                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
+                    self.name,
+                    stash_key,
+                    sorted(patch.keys()),
+                    eligible_ids,
+                )
+        except Exception as e:
+            logger.error(
+                "[VP %s] Failed to retroactively stamp queue item(s) %s for %s: %s",
+                self.name,
+                queue_item_ids,
+                stash_key,
+                e,
+            )
+        finally:
+            self._recent_queue_items.pop(stash_key, None)
 
     def _schedule_finish_release(self, filename: str, delay: float = 1.5) -> None:
         """Re-set gcode_state=FINISH on the VP after the project_file ack.
@@ -505,24 +660,38 @@ class VirtualPrinterInstance:
         # 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_*
+        # is a few hundred ms. The window is generous enough to absorb
+        # wireless / loaded-Pi jitter without making every VP-queue add
+        # visibly slow — observed worst case in #1780 round 3 was 2.085 s,
+        # the previous 2.0 s ceiling. 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.
+        # going through `on_print_command`, so they don't pay the wait 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)
+                await asyncio.wait_for(event.wait(), timeout=_SLICER_OPTIONS_WAIT_TIMEOUT)
                 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)
+        # If the cache still misses, queued workflow flags / nozzle pick will
+        # silently fall back to settings defaults. Surface the missed key so a
+        # future stash/lookup mismatch (the #1780 root cause) is obvious in
+        # the log instead of needing a wire capture to diagnose.
+        if slicer_opts is None:
+            logger.debug(
+                "[VP %s] No slicer options cached for %r (cache keys: %s); "
+                "workflow flags + nozzle pick will fall back to settings defaults.",
+                self.name,
+                file_path.name,
+                sorted(self._slicer_print_options.keys()),
+            )
 
         try:
             import json
@@ -571,6 +740,41 @@ class VirtualPrinterInstance:
                 )
                 timelapse = _slicer_or("timelapse", _bool_setting(await get_setting(db, "default_timelapse"), False))
 
+                # H2C dual-nozzle-rack slicer-pick preservation (#1780).
+                # BambuStudio's project_file MQTT command for rack-swap models
+                # (O1C2 today) carries `nozzle_mapping` — a per-filament array
+                # of physical nozzle position IDs (`list[int]`). Forward it
+                # verbatim onto the queue item so the dispatcher can replay it
+                # in its own project_file command. Without this the H2C
+                # firmware falls back to "last matching nozzle" auto-pick and
+                # ignores the user's Bambu Studio choice. Every other model
+                # has it absent from slicer_opts, so the capture is a
+                # transparent no-op there. (`nozzles_info` was also captured
+                # in the original fix but BambuStudio never actually sends it
+                # — verified via wire capture on H2C — so only `nozzle_mapping`
+                # is forwarded now.)
+                nozzle_mapping_json: str | None = None
+                if slicer_opts is not None:
+                    raw = slicer_opts.get("nozzle_mapping")
+                    if raw is not None:
+                        # BambuStudio's NetworkAgent embeds this as parsed
+                        # JSON in the project_file body (matching the
+                        # ams_mapping shape Bambuddy already consumes as
+                        # list[int]). Accept a JSON-encoded string defensively
+                        # in case any path arrives stringified.
+                        if isinstance(raw, str):
+                            try:
+                                raw = json.loads(raw)
+                            except json.JSONDecodeError:
+                                logger.warning(
+                                    "[VP %s] Slicer nozzle_mapping is unparseable JSON, dropping: %r",
+                                    self.name,
+                                    raw,
+                                )
+                                raw = None
+                        if raw is not None:
+                            nozzle_mapping_json = json.dumps(raw)
+
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -668,11 +872,49 @@ class VirtualPrinterInstance:
                             vibration_cali=vibration_cali,
                             layer_inspect=layer_inspect,
                             timelapse=timelapse,
+                            # Per-VP opt-in for auto-print G-code injection (#1516).
+                            # Default off; when on, the scheduler still no-ops unless
+                            # gcode_snippets are configured for the target model, so it's
+                            # effectively "inject when enabled AND snippets exist".
+                            gcode_injection=self.gcode_injection,
+                            # H2C rack-swap slicer pick (#1780). Captured above;
+                            # stamped on every plate so a multi-plate Send All keeps
+                            # the same nozzle pick across plates rather than only the
+                            # first one (mirrors the #1697 / #1188 per-plate loop fix).
+                            nozzle_mapping=nozzle_mapping_json,
                         )
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         queue_item_ids.append(queue_item.id)
                     await db.commit()
+                    # Track the freshly-committed queue items so
+                    # `on_print_command` can retroactively stamp slicer-side
+                    # fields if the MQTT `project_file` lands AFTER the
+                    # `_SLICER_OPTIONS_WAIT_TIMEOUT` window expired — the
+                    # #1780 round-3 race. Eviction of stale entries here
+                    # keeps the dict bounded; the queue path is the only
+                    # writer, so doing it on commit is enough.
+                    now = time.monotonic()
+                    cutoff = now - _RECENT_QUEUE_ITEM_TTL
+                    self._recent_queue_items = {k: v for k, v in self._recent_queue_items.items() if v[1] > cutoff}
+                    self._recent_queue_items[file_path.name] = (list(queue_item_ids), now)
+                    # Last-chance check: MQTT for this filename could have
+                    # arrived during ANY await between the initial pop and
+                    # now — wait_for itself, archive_print, db.flush,
+                    # db.commit. In all those cases `on_print_command`
+                    # stashed its data but neither the event-signal path nor
+                    # the retroactive `_recent_queue_items` path was in
+                    # place to consume it. Pop any late stash and apply
+                    # inline so the late MQTT never leaks past the queue-add.
+                    late_opts = self._slicer_print_options.pop(file_path.name, None)
+                    if late_opts is not None:
+                        logger.info(
+                            "[VP %s] Late slicer MQTT detected for %s during queue-add — "
+                            "applying inline (race vs commit/archive/flush yield)",
+                            self.name,
+                            file_path.name,
+                        )
+                        await self._restamp_recent_queue_item(file_path.name, late_opts)
                     if len(queue_item_ids) == 1:
                         logger.info("[VP %s] Added to queue: %s", self.name, queue_item_ids[0])
                     else:
@@ -1226,6 +1468,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
+                or instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
             )
 
@@ -1279,6 +1522,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
+                    gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     tailscale_disabled=vp.tailscale_disabled,

+ 8 - 0
backend/app/services/virtual_printer/mqtt_server.py

@@ -1293,6 +1293,14 @@ class SimpleMQTTServer:
                     file_3mf = print_data.get("file", filename)
                     await self._send_print_response(writer, sequence_id, file_3mf, serial=client_serial)
                     if self.on_print_command:
+                        # `filename` is the slicer's `subtask_name` (bare model
+                        # name, no extension). Pass it through verbatim — the
+                        # `_schedule_finish_release` chain echoes it back as
+                        # gcode_file + subtask_name in push_status, and the
+                        # slicer matches against its own subtask_name there.
+                        # The FTP filename (with extension) is in print_data
+                        # under "file" for the queue-stash side to use as its
+                        # own key matching `_add_to_print_queue`'s lookup.
                         await self._notify_print_command(filename, print_data)
                     handled_locally = True
 

+ 108 - 4
backend/app/utils/threemf_tools.py

@@ -5,6 +5,7 @@ per-layer filament usage data from the embedded G-code. This enables
 accurate partial usage reporting for multi-material prints.
 """
 
+import hashlib
 import json
 import logging
 import math
@@ -484,6 +485,64 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
     return filament_usage
 
 
+def extract_print_time_from_3mf(file_path: Path, plate_id: int | None = None) -> int | None:
+    """Extract the slicer's predicted print time from a 3MF's slice_info.config.
+
+    Multi-plate 3MFs carry one ``<plate><metadata key="prediction" .../></plate>``
+    per plate. The archive-level `print_time_seconds` is the sum across all plates
+    (see services/archive.py:200-264, #1593). For per-plate UI / notifications,
+    callers re-read the 3MF and request the specific plate's value via this helper.
+
+    Args:
+        file_path: Path to the 3MF file
+        plate_id: Plate index to filter for; if None, returns the first plate's
+            ``prediction`` (matches the legacy single-plate read).
+
+    Returns:
+        Predicted print time in seconds, or None if not found / unparseable.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            if "Metadata/slice_info.config" not in zf.namelist():
+                return None
+
+            content = zf.read("Metadata/slice_info.config").decode()
+            root = ET.fromstring(content)
+
+            if plate_id is not None:
+                for plate_elem in root.findall(".//plate"):
+                    plate_index = None
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "index":
+                            try:
+                                plate_index = int(meta.get("value", "0"))
+                            except ValueError:
+                                pass  # Skip plate with unparseable index
+                            break
+
+                    if plate_index == plate_id:
+                        for meta in plate_elem.findall("metadata"):
+                            if meta.get("key") == "prediction":
+                                try:
+                                    return int(meta.get("value", "0"))
+                                except ValueError:
+                                    return None
+                        break
+            else:
+                plate_elem = root.find(".//plate")
+                if plate_elem is not None:
+                    for meta in plate_elem.findall("metadata"):
+                        if meta.get("key") == "prediction":
+                            try:
+                                return int(meta.get("value", "0"))
+                            except ValueError:
+                                return None
+    except Exception as e:
+        logger.warning("Failed to extract print time from %s: %s", file_path, e)
+
+    return None
+
+
 def extract_bed_type_from_3mf(file_path: Path, plate_id: int | None = None) -> str | None:
     """Extract the build plate type (`curr_bed_type`) for a specific plate (#1281).
 
@@ -546,6 +605,7 @@ _HEADER_PLACEHOLDER_ALIASES = {
 _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
 _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
 _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
+_EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END"
 
 
 def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
@@ -618,6 +678,29 @@ def _inject_start_at_marker(content: str, snippet: str) -> str:
     return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
 
 
+def _inject_end_before_marker(content: str, snippet: str) -> str:
+    """Insert snippet immediately before `; EXECUTABLE_BLOCK_END`.
+
+    The end snippet must run *inside* the executable block. Bambu firmware
+    (verified on a P1S) does not execute G-code that sits after
+    `; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the
+    snippet — auto-eject / plate-clear moves never fire. Inserting before the
+    marker places the snippet after the printer's own machine-end sequence but
+    still within the executed block. Falls back to appending at the file end if
+    the marker isn't present.
+    """
+    marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER)
+    if marker_idx == -1:
+        logger.warning(
+            "G-code injection: '%s' not found, appending end snippet to file end",
+            _EXECUTABLE_BLOCK_END_MARKER,
+        )
+        return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n"
+    line_start = content.rfind("\n", 0, marker_idx)
+    line_start = 0 if line_start == -1 else line_start + 1
+    return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
+
+
 def inject_gcode_into_3mf(
     source_path: Path,
     plate_id: int,
@@ -629,8 +712,12 @@ def inject_gcode_into_3mf(
     Snippets support `{placeholder}` substitution against values parsed from
     the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
     snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
-    run after the printer's own startup (#422). End snippets are appended
-    after the last line of the print.
+    run after the printer's own startup (#422). End snippets are inserted just
+    before `; EXECUTABLE_BLOCK_END` so they run inside the executable block —
+    Bambu firmware (P1S) ignores g-code placed after that marker.
+
+    The plate's `.gcode.md5` sidecar is recomputed so firmware that validates
+    it against the gcode (e.g. P1S) still accepts the modified file.
 
     Args:
         source_path: Path to the original 3MF file.
@@ -672,10 +759,25 @@ def inject_gcode_into_3mf(
 
             if start_gcode:
                 resolved = _substitute_placeholders(start_gcode, header)
+                # Log the post-substitution snippet so the actually-injected G-code
+                # (placeholders like {max_layer_z} already resolved) is visible at DEBUG.
+                logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved)
                 gcode_content = _inject_start_at_marker(gcode_content, resolved)
             if end_gcode:
                 resolved = _substitute_placeholders(end_gcode, header)
-                gcode_content = gcode_content.rstrip("\n") + "\n" + resolved + "\n"
+                logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved)
+                gcode_content = _inject_end_before_marker(gcode_content, resolved)
+
+            # The printer validates the plate gcode against an embedded
+            # `<plate>.gcode.md5` sidecar (uppercase hex, no trailing newline).
+            # Rewriting the gcode without refreshing this hash makes firmware
+            # reject the file at load (P1S: HMS 0500-4003 "unable to parse"),
+            # so recompute it from the exact bytes we're about to write.
+            gcode_bytes = gcode_content.encode("utf-8")
+            md5_name = target_gcode + ".md5"
+            # Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar
+            # format, so flag it as non-security for the linters (ruff S324 / bandit B324).
+            md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii")
 
             # Write modified 3MF to temp file
             with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
@@ -685,7 +787,9 @@ def inject_gcode_into_3mf(
                 for item in zf.namelist():
                     info = zf.getinfo(item)
                     if item == target_gcode:
-                        zf_write.writestr(info, gcode_content.encode("utf-8"))
+                        zf_write.writestr(info, gcode_bytes)
+                    elif item == md5_name:
+                        zf_write.writestr(info, md5_value)
                     else:
                         zf_write.writestr(info, zf.read(item))
 

+ 15 - 0
backend/tests/conftest.py

@@ -81,6 +81,20 @@ def mfa_encryption_isolation(monkeypatch, tmp_path):
     enc_mod._key_source = None
 
 
+@pytest.fixture(autouse=True)
+def reset_spoolman_location_sync_cache():
+    """Drop the per-URL Spoolman location-sync TTL cache between tests.
+
+    Without this, a test that runs the sync against `http://localhost:7912`
+    will skip the sync in any later test that uses the same URL within 60
+    real seconds — test ordering would then leak assertions across runs."""
+    from backend.app.services.location_service import _spoolman_location_sync_cache_clear
+
+    _spoolman_location_sync_cache_clear()
+    yield
+    _spoolman_location_sync_cache_clear()
+
+
 @pytest.fixture(scope="session")
 def event_loop():
     """Create an instance of the default event loop for each test session."""
@@ -125,6 +139,7 @@ async def test_engine():
         slot_preset,
         smart_plug,
         smart_plug_energy_snapshot,  # noqa: F401
+        sponsor_toast_state,  # noqa: F401
         spool,
         spool_assignment,
         spool_catalog,

+ 19 - 0
backend/tests/integration/test_auth_api.py

@@ -854,6 +854,25 @@ class TestAuthMiddlewarePublicRoutes:
         assert response.status_code == 200
         assert "auth_enabled" in response.json()
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_system_appliance_is_public(self, async_client: AsyncClient, enabled_auth):
+        """Verify /api/v1/system/appliance is reachable without a JWT.
+
+        The SPA's i18n bootstrap fetches this BEFORE login to seed locale,
+        hostname, timezone, and NTP-gate state. The route handler has no
+        auth dependency, but the global auth_middleware blocks every
+        /api/ path not in PUBLIC_API_ROUTES — so without an explicit
+        allowlist entry the user sees a 401 in the browser console on
+        every page load.
+        """
+        response = await async_client.get("/api/v1/system/appliance")
+        assert response.status_code == 200, response.text
+        body = response.json()
+        # Shape contract (no-auth surface):
+        for key in ("hostname", "timezone", "locale", "time_synced"):
+            assert key in body
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_auth_login_is_public(self, async_client: AsyncClient, enabled_auth):

+ 10 - 2
backend/tests/integration/test_auth_apikey_rbac.py

@@ -96,6 +96,7 @@ class TestApiKeyRbacAllowed:
         mock_client.base_url = "http://localhost:7912"
         mock_client.health_check = AsyncMock(return_value=True)
         mock_client.get_all_spools = AsyncMock(return_value=[])
+        mock_client.get_distinct_locations = AsyncMock(return_value=[])
         with patch(
             "backend.app.api.routes.spoolman_inventory._get_client",
             AsyncMock(return_value=mock_client),
@@ -299,9 +300,15 @@ class TestCheckApiKeyPermissionsMatrix:
         ("PRINTERS_CONTROL", "can_control_printer", "start/stop print"),
         ("PRINTERS_FILES", "can_control_printer", "send file to printer"),
         ("SMART_PLUGS_CONTROL", "can_control_printer", "smart plug on/off"),
-        # can_manage_library
+        # can_manage_library — OWN and ALL ownership variants both fold into
+        # the same scope (#1832): API keys have no per-row ownership identity,
+        # so splitting OWN/ALL across allowlist/denylist made the curation
+        # surface unreachable. PURGE stays admin-only.
         ("LIBRARY_UPLOAD", "can_manage_library", "upload library file"),
+        ("LIBRARY_UPDATE_OWN", "can_manage_library", "rename own library file"),
+        ("LIBRARY_UPDATE_ALL", "can_manage_library", "rename any library file"),
         ("LIBRARY_DELETE_OWN", "can_manage_library", "delete own library file"),
+        ("LIBRARY_DELETE_ALL", "can_manage_library", "delete any library file"),
         ("MAKERWORLD_IMPORT", "can_manage_library", "import from MakerWorld"),
         # can_manage_inventory
         ("INVENTORY_CREATE", "can_manage_inventory", "create spool record"),
@@ -320,7 +327,8 @@ class TestCheckApiKeyPermissionsMatrix:
         "FIRMWARE_UPDATE",
         # Unmapped administrative (allowlist fail-closed catches these too)
         "PRINTERS_CREATE",
-        "LIBRARY_DELETE_ALL",
+        # LIBRARY_DELETE_ALL / LIBRARY_UPDATE_ALL moved to can_manage_library
+        # under #1832 — covered by the _SCOPE_CASES matrix above.
         "LIBRARY_PURGE",
         "DISCOVERY_SCAN",
     ]

+ 19 - 289
backend/tests/integration/test_background_dispatch_api.py

@@ -1,306 +1,36 @@
-"""Integration tests for background dispatch API behavior."""
-
-from unittest.mock import AsyncMock, patch
+"""Integration tests for removed direct print API behavior."""
 
 import pytest
 from httpx import AsyncClient
 
-from backend.app.services.background_dispatch import DispatchEnqueueRejected
-
 
-class TestBackgroundDispatchArchivesAPI:
-    """Tests for archive reprint dispatch endpoint."""
+class TestLegacyArchivePrintAPI:
+    """Tests for the removed archive reprint dispatch endpoint."""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_reprint_returns_dispatched_payload(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
-    ):
-        """Reprint endpoint returns background dispatch metadata."""
-        printer = await printer_factory()
-        archive = await archive_factory(
-            printer.id,
-            filename="widget.gcode.3mf",
-            file_path="archives/test/widget.gcode.3mf",
+    async def test_reprint_route_returns_410_and_does_not_dispatch(self, async_client: AsyncClient):
+        """Legacy direct reprint endpoint is gone; callers must create queue items."""
+        response = await async_client.post(
+            "/api/v1/archives/123/reprint?printer_id=456",
+            json={"plate_id": 2},
         )
 
-        archive_file = tmp_path / archive.file_path
-        archive_file.parent.mkdir(parents=True, exist_ok=True)
-        archive_file.write_bytes(b"3mf-data")
+        assert response.status_code == 410
+        assert "POST /queue/" in response.json()["detail"]
 
-        with (
-            patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
-            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
-            patch(
-                "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
-                new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
-            ) as mock_dispatch,
-        ):
-            response = await async_client.post(
-                f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
-                json={"plate_id": 2},
-            )
 
-        assert response.status_code == 200
-        data = response.json()
-        assert data["status"] == "dispatched"
-        assert data["dispatch_job_id"] == 15
-        assert data["dispatch_position"] == 1
-        assert data["filename"] == "widget.gcode.3mf"
-
-        mock_dispatch.assert_awaited_once()
-        kwargs = mock_dispatch.await_args.kwargs
-        assert kwargs["archive_name"].endswith("• Plate 2")
-        assert kwargs["options"]["plate_id"] == 2
+class TestLegacyLibraryPrintAPI:
+    """Tests for the removed library print dispatch endpoint."""
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_reprint_returns_409_when_enqueue_rejected(
-        self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
-    ):
-        """Reprint endpoint maps enqueue rejection to HTTP 409."""
-        printer = await printer_factory()
-        archive = await archive_factory(
-            printer.id,
-            filename="widget2.gcode.3mf",
-            file_path="archives/test/widget2.gcode.3mf",
+    async def test_library_print_route_returns_410_and_does_not_dispatch(self, async_client: AsyncClient):
+        """Legacy direct library print endpoint is gone; callers must create queue items."""
+        response = await async_client.post(
+            "/api/v1/library/files/123/print?printer_id=456",
+            json={"plate_id": 4},
         )
 
-        archive_file = tmp_path / archive.file_path
-        archive_file.parent.mkdir(parents=True, exist_ok=True)
-        archive_file.write_bytes(b"3mf-data")
-
-        with (
-            patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
-            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
-            patch(
-                "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
-                new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
-            ),
-        ):
-            response = await async_client.post(
-                f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
-                json={"plate_id": 1},
-            )
-
-        assert response.status_code == 409
-        assert "already has a background dispatch" in response.json()["detail"]
-
-
-class TestBackgroundDispatchLibraryAPI:
-    """Tests for library print dispatch endpoint."""
-
-    @pytest.fixture
-    async def library_file_factory(self, db_session):
-        """Factory to create library files."""
-
-        async def _create_file(**kwargs):
-            from backend.app.models.library import LibraryFile
-
-            defaults = {
-                "filename": "library_part.gcode.3mf",
-                "file_path": "library/files/library_part.gcode.3mf",
-                "file_type": "gcode",
-                "file_size": 1024,
-            }
-            defaults.update(kwargs)
-            lib_file = LibraryFile(**defaults)
-            db_session.add(lib_file)
-            await db_session.commit()
-            await db_session.refresh(lib_file)
-            return lib_file
-
-        return _create_file
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_library_print_returns_dispatched_payload(
-        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
-    ):
-        """Library print endpoint returns dispatch job metadata."""
-        printer = await printer_factory()
-        lib_file = await library_file_factory()
-
-        disk_path = tmp_path / lib_file.file_path
-        disk_path.parent.mkdir(parents=True, exist_ok=True)
-        disk_path.write_bytes(b"library data")
-
-        with (
-            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
-            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
-            patch(
-                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
-                new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
-            ) as mock_dispatch,
-        ):
-            response = await async_client.post(
-                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
-                json={"plate_id": 4},
-            )
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["status"] == "dispatched"
-        assert data["dispatch_job_id"] == 21
-        assert data["dispatch_position"] == 2
-        assert data["archive_id"] is None
-
-        mock_dispatch.assert_awaited_once()
-        kwargs = mock_dispatch.await_args.kwargs
-        assert kwargs["filename"].endswith("• Plate 4")
-        assert kwargs["options"]["plate_id"] == 4
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_library_print_returns_409_when_enqueue_rejected(
-        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
-    ):
-        """Library print endpoint maps enqueue rejection to HTTP 409."""
-        printer = await printer_factory()
-        lib_file = await library_file_factory(filename="another_part.gcode")
-
-        disk_path = tmp_path / lib_file.file_path
-        disk_path.parent.mkdir(parents=True, exist_ok=True)
-        disk_path.write_bytes(b"library data")
-
-        with (
-            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
-            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
-            patch(
-                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
-                new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
-            ),
-        ):
-            response = await async_client.post(
-                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
-                json={"plate_id": 1},
-            )
-
-        assert response.status_code == 409
-        assert "queue conflict" in response.json()["detail"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_library_print_cleanup_flag_defaults_false(
-        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
-    ):
-        """Absent cleanup_library_after_dispatch in the request body ⇒ False reaches the dispatcher.
-        Guards the File Manager / Project Detail paths from accidental deletion."""
-        printer = await printer_factory()
-        lib_file = await library_file_factory(filename="filemgr_part.gcode.3mf")
-
-        disk_path = tmp_path / lib_file.file_path
-        disk_path.parent.mkdir(parents=True, exist_ok=True)
-        disk_path.write_bytes(b"library data")
-
-        with (
-            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
-            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
-            patch(
-                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
-                new=AsyncMock(return_value={"dispatch_job_id": 30, "dispatch_position": 1}),
-            ) as mock_dispatch,
-        ):
-            response = await async_client.post(
-                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
-                json={},
-            )
-
-        assert response.status_code == 200
-        mock_dispatch.assert_awaited_once()
-        assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is False
-        # cleanup flag must never leak into the print-option dict forwarded to MQTT
-        assert "cleanup_library_after_dispatch" not in mock_dispatch.await_args.kwargs["options"]
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_library_print_forwards_cleanup_flag_true(
-        self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
-    ):
-        """Direct-Print flow sends cleanup_library_after_dispatch=True, which must reach the dispatcher."""
-        printer = await printer_factory()
-        lib_file = await library_file_factory(filename="transient_part.gcode.3mf")
-
-        disk_path = tmp_path / lib_file.file_path
-        disk_path.parent.mkdir(parents=True, exist_ok=True)
-        disk_path.write_bytes(b"library data")
-
-        with (
-            patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
-            patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
-            patch(
-                "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
-                new=AsyncMock(return_value={"dispatch_job_id": 31, "dispatch_position": 1}),
-            ) as mock_dispatch,
-        ):
-            response = await async_client.post(
-                f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
-                json={"cleanup_library_after_dispatch": True},
-            )
-
-        assert response.status_code == 200
-        mock_dispatch.assert_awaited_once()
-        assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is True
-
-
-class TestBackgroundDispatchCancelAPI:
-    """Tests for /background-dispatch cancel endpoint."""
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
-        """Cancel endpoint returns cancelled for queued job."""
-        with patch(
-            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
-            new=AsyncMock(
-                return_value={
-                    "cancelled": True,
-                    "pending": False,
-                    "job_id": 9,
-                    "source_name": "cube.gcode.3mf",
-                    "printer_id": 1,
-                    "printer_name": "Printer A",
-                }
-            ),
-        ):
-            response = await async_client.delete("/api/v1/background-dispatch/9")
-
-        assert response.status_code == 200
-        data = response.json()
-        assert data["status"] == "cancelled"
-        assert data["job_id"] == 9
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
-        """Cancel endpoint returns cancelling while active upload is being interrupted."""
-        with patch(
-            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
-            new=AsyncMock(
-                return_value={
-                    "cancelled": True,
-                    "pending": True,
-                    "job_id": 10,
-                    "source_name": "cube.gcode.3mf",
-                    "printer_id": 1,
-                    "printer_name": "Printer A",
-                }
-            ),
-        ):
-            response = await async_client.delete("/api/v1/background-dispatch/10")
-
-        assert response.status_code == 200
-        assert response.json()["status"] == "cancelling"
-
-    @pytest.mark.asyncio
-    @pytest.mark.integration
-    async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
-        """Cancel endpoint returns 404 for unknown job id."""
-        with patch(
-            "backend.app.services.background_dispatch.background_dispatch.cancel_job",
-            new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
-        ):
-            response = await async_client.delete("/api/v1/background-dispatch/999")
-
-        assert response.status_code == 404
-        assert response.json()["detail"] == "Dispatch job not found"
+        assert response.status_code == 410
+        assert "POST /queue/" in response.json()["detail"]

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

@@ -147,6 +147,39 @@ class TestCameraAPI:
         assert response.status_code == 200
         mock_shutdown.assert_awaited_once_with(f"printer-{printer.id}")
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_stop_camera_stream_skips_shutdown_when_subscribers_remain(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """Reference-count guard: when other viewers are still subscribed to the
+        broadcaster, /camera/stop must NOT force-shutdown — otherwise closing
+        the embedded viewer kills the cam-wall tile of the same printer.
+        Natural cleanup tears it down when the last HTTP connection closes.
+        """
+        printer = await printer_factory()
+
+        mock_shutdown = AsyncMock(return_value=True)
+        mock_process = MagicMock()
+        mock_process.returncode = None
+        mock_process.pid = 88888
+        mock_process.terminate = MagicMock()
+        mock_process.wait = AsyncMock()
+
+        with (
+            patch("backend.app.api.routes.camera.get_subscriber_count", return_value=2),
+            patch("backend.app.api.routes.camera.shutdown_broadcaster", mock_shutdown),
+            patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc": mock_process}),
+        ):
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["stopped"] == 0
+        assert result.get("skipped") is True
+        mock_shutdown.assert_not_awaited()
+        mock_process.terminate.assert_not_called()
+
     # ========================================================================
     # Camera Test Endpoint
     # ========================================================================

+ 212 - 0
backend/tests/integration/test_inventory_bulk.py

@@ -0,0 +1,212 @@
+"""Bulk inventory endpoint coverage for the batch-edit feature (#1795).
+
+Endpoints under test:
+- POST /api/v1/inventory/spools/bulk-update
+- POST /api/v1/inventory/spools/bulk-delete
+- POST /api/v1/inventory/spools/bulk-archive
+- POST /api/v1/inventory/spools/bulk-restore
+
+The Spoolman-mode equivalents live in test_spoolman_inventory_api.py.
+"""
+
+from datetime import datetime, timezone
+
+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):
+    async def _create(**kwargs):
+        defaults = {
+            "material": "PLA",
+            "subtype": "Basic",
+            "brand": "Bambu",
+            "color_name": "Red",
+            "rgba": "FF0000FF",
+            "label_weight": 1000,
+            "core_weight": 250,
+            "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 TestBulkUpdate:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_applies_patch_to_all_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory(brand="Bambu", note=None)
+        b = await spool_factory(brand="Bambu", note=None)
+        c = await spool_factory(brand="Bambu", note=None)
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id, b.id, c.id], "update": {"brand": "Sunlu", "note": "From bulk edit"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 3
+        assert body["not_found"] == []
+
+        for spool in (a, b, c):
+            await db_session.refresh(spool)
+            assert spool.brand == "Sunlu"
+            assert spool.note == "From bulk edit"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_unknown_ids_in_not_found(self, async_client: AsyncClient, spool_factory, db_session):
+        real = await spool_factory(brand="Bambu")
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [real.id, 999_999], "update": {"brand": "Sunlu"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 1
+        assert body["not_found"] == [999_999]
+
+        await db_session.refresh(real)
+        assert real.brand == "Sunlu"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_update_rejected(self, async_client: AsyncClient, spool_factory):
+        a = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id], "update": {}},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_setting_weight_used_auto_locks(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory(weight_locked=False, weight_used=0.0)
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [a.id], "update": {"weight_used": 250.5}},
+        )
+        assert resp.status_code == 200
+        await db_session.refresh(a)
+        assert a.weight_used == 250.5
+        assert a.weight_locked is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_ids_list_rejected(self, async_client: AsyncClient):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-update",
+            json={"ids": [], "update": {"brand": "X"}},
+        )
+        assert resp.status_code == 422
+
+
+class TestBulkDelete:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deletes_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory()
+        b = await spool_factory()
+        kept = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-delete",
+            json={"ids": [a.id, b.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 2
+        assert body["not_found"] == []
+
+        remaining = (await db_session.execute(select(Spool.id))).scalars().all()
+        assert kept.id in remaining
+        assert a.id not in remaining
+        assert b.id not in remaining
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
+        a = await spool_factory()
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-delete",
+            json={"ids": [a.id, 999_999]},
+        )
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 1
+        assert body["not_found"] == [999_999]
+
+
+class TestBulkArchiveRestore:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_sets_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
+        a = await spool_factory()
+        b = await spool_factory()
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-archive",
+            json={"ids": [a.id, b.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 2
+        assert body["already_archived"] == []
+        assert body["not_found"] == []
+
+        for s in (a, b):
+            await db_session.refresh(s)
+            assert s.archived_at is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_skips_already_archived(self, async_client: AsyncClient, spool_factory, db_session):
+        active = await spool_factory()
+        already = await spool_factory(archived_at=datetime.now(timezone.utc))
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-archive",
+            json={"ids": [active.id, already.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 1
+        assert body["already_archived"] == [already.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_restore_clears_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
+        archived = await spool_factory(archived_at=datetime.now(timezone.utc))
+        active = await spool_factory(archived_at=None)
+
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/bulk-restore",
+            json={"ids": [archived.id, active.id]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["restored"] == 1
+        assert body["already_active"] == [active.id]
+
+        await db_session.refresh(archived)
+        assert archived.archived_at is None

+ 69 - 0
backend/tests/integration/test_inventory_from_slot_no_tag.py

@@ -0,0 +1,69 @@
+"""No-tag guard on POST /api/v1/inventory/spools/from-slot.
+
+A slot that has tray_type set but no RFID tag (generic third-party PLA the
+user manually configured on the printer panel) has no stable identity, so
+auto-creating an inventory row for it would just duplicate on every confirm
+without ever re-linking to the physical spool. Verify the endpoint refuses
+that case with 400.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+def _mock_status_for_tray(ams_id: int, tray_id: int, tray: dict) -> MagicMock:
+    status = MagicMock()
+    status.raw_data = {"ams": {"ams": [{"id": ams_id, "tray": [{"id": tray_id, **tray}]}]}}
+    return status
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_from_slot_rejects_no_tag(async_client: AsyncClient, printer_factory):
+    printer = await printer_factory(name="X1C-no-tag")
+
+    no_tag_tray = {
+        "tray_type": "PLA",
+        "tray_color": "FF0000FF",
+        "tag_uid": "0000000000000000",
+        "tray_uuid": "00000000000000000000000000000000",
+    }
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.get_status",
+        return_value=_mock_status_for_tray(0, 1, no_tag_tray),
+    ):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/from-slot",
+            json={"printer_id": printer.id, "ams_id": 0, "tray_id": 1},
+        )
+
+    assert resp.status_code == 400
+    assert "RFID tag" in resp.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_from_slot_rejects_empty_tag_strings(async_client: AsyncClient, printer_factory):
+    """Empty strings (not zero-filled) must also be refused."""
+    printer = await printer_factory(name="X1C-empty-tag")
+
+    empty_tag_tray = {
+        "tray_type": "PETG",
+        "tray_color": "00FF00FF",
+        "tag_uid": "",
+        "tray_uuid": "",
+    }
+
+    with patch(
+        "backend.app.services.printer_manager.printer_manager.get_status",
+        return_value=_mock_status_for_tray(0, 2, empty_tag_tray),
+    ):
+        resp = await async_client.post(
+            "/api/v1/inventory/spools/from-slot",
+            json={"printer_id": printer.id, "ams_id": 0, "tray_id": 2},
+        )
+
+    assert resp.status_code == 400

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

@@ -44,6 +44,61 @@ class TestLibraryFoldersAPI:
         assert response.status_code == 200
         assert response.json() == []
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_tree_exposes_latest_activity_at_from_files(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """#1770: folder list returns latest_activity_at = MAX(folder.updated_at,
+        MAX(immediate-child file.updated_at)) so the frontend can sort by
+        recent activity. Adding a file with a later updated_at must bubble it.
+        """
+        from datetime import datetime, timedelta
+
+        from backend.app.models.library import LibraryFile
+
+        folder = await folder_factory(name="Active Folder")
+        # File whose updated_at is well after the folder's. Activity should
+        # surface this timestamp, not the folder's stale one.
+        future = datetime.utcnow() + timedelta(hours=24)
+        db_session.add(
+            LibraryFile(
+                folder_id=folder.id,
+                filename="model.3mf",
+                file_path="library/model.3mf",
+                file_type="3mf",
+                file_size=123,
+                updated_at=future,
+            )
+        )
+        await db_session.commit()
+
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) == 1
+        item = items[0]
+        assert item["id"] == folder.id
+        assert item["latest_activity_at"] is not None
+        # latest_activity_at should be at least the future stamp we set.
+        assert item["latest_activity_at"] >= future.isoformat()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at(
+        self, async_client: AsyncClient, folder_factory, db_session
+    ):
+        """#1770: a folder with no files reports its own updated_at, not null —
+        otherwise the activity sort would dump every empty folder to one end."""
+        await folder_factory(name="Empty Folder")
+        response = await async_client.get("/api/v1/library/folders")
+        assert response.status_code == 200
+        items = response.json()
+        assert len(items) == 1
+        item = items[0]
+        # latest_activity_at == folder.updated_at when there are no files
+        assert item["latest_activity_at"] is not None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_create_folder(self, async_client: AsyncClient, db_session):
@@ -432,6 +487,139 @@ class TestLibraryFilesAPI:
         assert test_file["created_by_id"] == user.id
         assert test_file["created_by_username"] == "testuploader"
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_recursive_includes_subfolders(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """#1268: ?recursive=true with folder_id must include every descendant.
+
+        Tree:
+            toys/             ← f_toys, direct file "robot_top.3mf"
+              cars/           ← child of toys, file "robot_car.3mf"
+                race/         ← grandchild, file "robot_race.3mf"
+            other/            ← unrelated, file "robot_other.3mf" (must NOT appear)
+        """
+        toys = await folder_factory(name="toys")
+        cars = await folder_factory(name="cars", parent_id=toys.id)
+        race = await folder_factory(name="race", parent_id=cars.id)
+        other = await folder_factory(name="other")
+
+        top = await file_factory(folder_id=toys.id, filename="robot_top.3mf")
+        mid = await file_factory(folder_id=cars.id, filename="robot_car.3mf")
+        deep = await file_factory(folder_id=race.id, filename="robot_race.3mf")
+        await file_factory(folder_id=other.id, filename="robot_other.3mf")
+
+        # Non-recursive: only the file directly under toys.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {top.id}
+
+        # Recursive: toys + cars + race files, but NOT other/.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}&recursive=true")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {top.id, mid.id, deep.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_files_recursive_without_folder_id_is_noop(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """recursive=true is meaningful only with folder_id — without it the
+        existing include_root branch handles scoping. Just confirming the new
+        param doesn't shadow that path."""
+        folder = await folder_factory()
+        f_in = await file_factory(folder_id=folder.id)
+        f_root = await file_factory()
+
+        r = await async_client.get("/api/v1/library/files?include_root=false&recursive=true")
+        assert r.status_code == 200
+        assert {f["id"] for f in r.json()} == {f_in.id, f_root.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_returns_first_markdown(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """#1268: /folders/{id}/readme reads on-disk content of the first .md."""
+        folder = await folder_factory()
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("# Robot\n\nA cute little robot.")
+            md_path = f.name
+        try:
+            await file_factory(
+                folder_id=folder.id,
+                filename="README.md",
+                file_path=md_path,
+                file_type="md",
+                file_size=Path(md_path).stat().st_size,
+            )
+            r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+            assert r.status_code == 200
+            body = r.json()
+            assert body["filename"] == "README.md"
+            assert body["content"] == "# Robot\n\nA cute little robot."
+            assert body["truncated"] is False
+        finally:
+            import os
+
+            os.unlink(md_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_prefers_readme_over_other_md(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """When the folder has multiple .md files, README.md / description.md
+        wins regardless of insertion order or filename case."""
+        folder = await folder_factory()
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("notes notes notes")
+            notes_path = f.name
+        with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
+            f.write("the real one")
+            readme_path = f.name
+        try:
+            # notes.md inserted FIRST — naive ordering would pick this one.
+            await file_factory(
+                folder_id=folder.id,
+                filename="notes.md",
+                file_path=notes_path,
+                file_type="md",
+            )
+            await file_factory(
+                folder_id=folder.id,
+                filename="readme.md",  # lowercase to confirm case-insensitive match
+                file_path=readme_path,
+                file_type="md",
+            )
+            r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+            assert r.status_code == 200
+            assert r.json()["filename"] == "readme.md"
+            assert r.json()["content"] == "the real one"
+        finally:
+            import os
+
+            os.unlink(notes_path)
+            os.unlink(readme_path)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_404_when_no_markdown(
+        self, async_client: AsyncClient, folder_factory, file_factory
+    ):
+        """No .md in the folder → 404 so the FE can hide the side panel."""
+        folder = await folder_factory()
+        await file_factory(folder_id=folder.id, filename="model.3mf", file_type="3mf")
+        r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
+        assert r.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_folder_readme_404_when_folder_missing(self, async_client: AsyncClient):
+        r = await async_client.get("/api/v1/library/folders/999999/readme")
+        assert r.status_code == 404
+
 
 class TestLibraryAddToQueueAPI:
     """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
@@ -1161,6 +1349,140 @@ class TestLibraryPermissions:
         # Viewers don't have delete_own or delete_all permissions
         assert response.status_code == 403
 
+    # ---------- #1832: API-key curation under can_manage_library ----------
+    #
+    # require_ownership_permission gates API keys on `all_perm`, but the
+    # library deliberately split UPDATE_OWN/DELETE_OWN (allowed under
+    # can_manage_library) from UPDATE_ALL/DELETE_ALL (previously denied).
+    # That made the entire curation surface (DELETE, PUT rename, POST move)
+    # unreachable for API keys, including for files the key's owner uploaded.
+    # The fix folds UPDATE_ALL/DELETE_ALL into can_manage_library so the
+    # checker passes; LIBRARY_PURGE stays admin-only.
+
+    @pytest.fixture
+    async def manage_library_key(self, db_session, auth_setup):
+        """Mint an API key owned by the admin user with can_manage_library."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        admin = auth_setup["admin_user"]
+        full_key, key_hash, key_prefix = generate_api_key()
+        row = APIKey(
+            name="lib-curation",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=admin.id,
+            can_manage_library=True,
+        )
+        db_session.add(row)
+        await db_session.commit()
+        return full_key
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_apikey_with_manage_library_can_delete_file(
+        self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
+    ):
+        """Pre-#1832 this 403'd with "administrative operations" because
+        LIBRARY_DELETE_ALL wasn't in _APIKEY_SCOPE_BY_PERMISSION."""
+        from pathlib import Path
+
+        from backend.app.core.config import settings as app_settings
+
+        # Materialise the file on disk so the delete handler doesn't 500 on
+        # the path it tries to unlink.
+        file_path = Path(app_settings.base_dir) / test_file.file_path
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        file_path.write_text("test content")
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{test_file.id}",
+            headers={"X-API-Key": manage_library_key},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_apikey_with_manage_library_can_rename_file(
+        self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
+    ):
+        """PUT /library/files/{id} is gated on LIBRARY_UPDATE_ALL/OWN. Same
+        #1832 path as delete."""
+        response = await async_client.put(
+            f"/api/v1/library/files/{test_file.id}",
+            headers={"X-API-Key": manage_library_key},
+            json={"filename": "renamed.txt"},
+        )
+        assert response.status_code == 200, response.text
+        assert response.json()["filename"] == "renamed.txt"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_apikey_with_manage_library_can_move_file(
+        self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
+    ):
+        """POST /library/files/move (bulk) is gated on LIBRARY_UPDATE_ALL/OWN
+        — same checker, same #1832 path."""
+        # Create a target folder the move can land in.
+        from backend.app.models.library import LibraryFolder
+
+        folder = LibraryFolder(name="target")
+        db_session.add(folder)
+        await db_session.commit()
+        await db_session.refresh(folder)
+
+        response = await async_client.post(
+            "/api/v1/library/files/move",
+            headers={"X-API-Key": manage_library_key},
+            json={"file_ids": [test_file.id], "folder_id": folder.id},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_apikey_without_manage_library_still_blocked(
+        self, async_client: AsyncClient, db_session, auth_setup, test_file
+    ):
+        """Regression guard: a key WITHOUT can_manage_library must still get
+        403 — the fix widens the allowed-permission set, it doesn't bypass
+        the per-key scope check."""
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        admin = auth_setup["admin_user"]
+        full_key, key_hash, key_prefix = generate_api_key()
+        row = APIKey(
+            name="read-only",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            user_id=admin.id,
+            can_read_status=True,
+            can_manage_library=False,
+        )
+        db_session.add(row)
+        await db_session.commit()
+
+        response = await async_client.delete(
+            f"/api/v1/library/files/{test_file.id}",
+            headers={"X-API-Key": full_key},
+        )
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_apikey_with_manage_library_still_cannot_purge(
+        self, async_client: AsyncClient, db_session, auth_setup, manage_library_key
+    ):
+        """LIBRARY_PURGE deliberately stays in _APIKEY_DENIED_PERMISSIONS as
+        a genuinely destructive op that bypasses the soft-delete window.
+        can_manage_library does NOT grant it."""
+        response = await async_client.post(
+            "/api/v1/library/purge",
+            headers={"X-API-Key": manage_library_key},
+            json={"days_in_trash": 30},
+        )
+        assert response.status_code == 403
+
 
 class TestPrintFileUploadValidation:
     """#1401: pre-flight rejection of unprintable uploads at the library +

+ 288 - 0
backend/tests/integration/test_library_tags_api.py

@@ -0,0 +1,288 @@
+"""Integration tests for the library tag catalog + bulk-assign (#1268)."""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def folder_factory(db_session):
+    """Minimal folder factory shared across the tests in this module."""
+    _counter = [0]
+
+    async def _create_folder(**kwargs):
+        from backend.app.models.library import LibraryFolder
+
+        _counter[0] += 1
+        defaults = {"name": f"Folder {_counter[0]}"}
+        defaults.update(kwargs)
+        f = LibraryFolder(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create_folder
+
+
+@pytest.fixture
+async def file_factory(db_session):
+    """Minimal file factory shared across the tests in this module."""
+    _counter = [0]
+
+    async def _create_file(**kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"file_{_counter[0]}.3mf",
+            "file_path": f"/test/file_{_counter[0]}.3mf",
+            "file_size": 100,
+            "file_type": "3mf",
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create_file
+
+
+class TestLibraryTagCRUD:
+    """Catalog CRUD: create / list / rename / delete."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tag_and_list(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "toy"})
+        assert r.status_code == 201
+        body = r.json()
+        assert body["name"] == "toy"
+        assert body["file_count"] == 0
+
+        r = await async_client.get("/api/v1/library/tags")
+        assert r.status_code == 200
+        names = [t["name"] for t in r.json()]
+        assert "toy" in names
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_tag_strips_whitespace(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "  kid-safe  "})
+        assert r.status_code == 201
+        assert r.json()["name"] == "kid-safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_duplicate_case_insensitive_409(self, async_client: AsyncClient):
+        """'Toys' / 'toys' / 'TOYS  ' all collide on name_key."""
+        r1 = await async_client.post("/api/v1/library/tags", json={"name": "Toys"})
+        assert r1.status_code == 201
+        for dup in ("toys", "TOYS", "  ToYs  "):
+            r = await async_client.post("/api/v1/library/tags", json={"name": dup})
+            assert r.status_code == 409, dup
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_tag(self, async_client: AsyncClient):
+        r = await async_client.post("/api/v1/library/tags", json={"name": "kidsafe"})
+        tag_id = r.json()["id"]
+        r = await async_client.patch(f"/api/v1/library/tags/{tag_id}", json={"name": "kid-safe"})
+        assert r.status_code == 200
+        assert r.json()["name"] == "kid-safe"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rename_collision_409(self, async_client: AsyncClient):
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "b"})).json()
+        # Renaming b → A (case-insensitive collision with a) must fail.
+        r = await async_client.patch(f"/api/v1/library/tags/{b['id']}", json={"name": "A"})
+        assert r.status_code == 409
+        # Renaming a row to its own current name (round-trip with the same key)
+        # must NOT 409 — the pre-check excludes the tag itself.
+        r = await async_client.patch(f"/api/v1/library/tags/{a['id']}", json={"name": "a"})
+        assert r.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_tag_cascades_associations_keeps_file(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "x"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [tag["id"]], "action": "add"},
+        )
+        r = await async_client.delete(f"/api/v1/library/tags/{tag['id']}")
+        assert r.status_code == 204
+
+        # Tag list no longer contains it.
+        names = [t["name"] for t in (await async_client.get("/api/v1/library/tags")).json()]
+        assert "x" not in names
+        # File still listed (CASCADE only dropped the association row).
+        r = await async_client.get(
+            f"/api/v1/library/files?folder_id={f.folder_id}" if f.folder_id else "/api/v1/library/files"
+        )
+        assert any(item["id"] == f.id for item in r.json())
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_unknown_tag_404(self, async_client: AsyncClient):
+        r = await async_client.delete("/api/v1/library/tags/999999")
+        assert r.status_code == 404
+
+
+class TestLibraryTagBulkAssign:
+    """Bulk-assign: add / remove / replace + per-action assertions."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_is_idempotent(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        t = (await async_client.post("/api/v1/library/tags", json={"name": "t"})).json()
+        payload = {"file_ids": [f.id], "tag_ids": [t["id"]], "action": "add"}
+        r1 = await async_client.post("/api/v1/library/tags/bulk-assign", json=payload)
+        assert r1.status_code == 200
+        assert r1.json()["associations_added"] == 1
+        r2 = await async_client.post("/api/v1/library/tags/bulk-assign", json=payload)
+        # Second call adds 0 — pair already exists; route remains 200 not 409.
+        assert r2.status_code == 200
+        assert r2.json()["associations_added"] == 0
+        # And the file_count for the tag is still exactly 1.
+        tags = (await async_client.get("/api/v1/library/tags")).json()
+        assert next(x["file_count"] for x in tags if x["id"] == t["id"]) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_remove_drops_only_listed_tags(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "b"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"], b["id"]], "action": "add"},
+        )
+        # Remove only `a`. `b` should still be on the file.
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"]], "action": "remove"},
+        )
+        assert r.status_code == 200
+        assert r.json()["associations_removed"] == 1
+        # Tag-filter listing by `b` still returns the file.
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={b['id']}")
+        assert {x["id"] for x in r.json()} == {f.id}
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}")
+        assert {x["id"] for x in r.json()} == set()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_replace_with_empty_tag_set_clears(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "a"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [a["id"]], "action": "add"},
+        )
+        # Replace with [] → file ends up with no tags.
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [], "action": "replace"},
+        )
+        assert r.status_code == 200
+        assert r.json()["associations_removed"] == 1
+        # File listing shows empty tags array.
+        r = await async_client.get("/api/v1/library/files?include_root=false")
+        item = next(x for x in r.json() if x["id"] == f.id)
+        assert item["tags"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_file_ids_silently_skipped(self, async_client: AsyncClient, file_factory):
+        """Unknown / inaccessible file ids must not 404 the whole call — the
+        caller may be racing a delete or have a stale selection. Counts reflect
+        what actually happened."""
+        f = await file_factory()
+        t = (await async_client.post("/api/v1/library/tags", json={"name": "t"})).json()
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id, 999999], "tag_ids": [t["id"]], "action": "add"},
+        )
+        assert r.status_code == 200
+        body = r.json()
+        assert body["files_updated"] == 1
+        assert body["associations_added"] == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_action_422(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        r = await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [], "action": "nuke"},
+        )
+        assert r.status_code == 422
+
+
+class TestLibraryTagFilter:
+    """list_files?tag_ids=… — AND semantics + folder bypass."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_and_semantics(self, async_client: AsyncClient, file_factory):
+        a_only = await file_factory(filename="a_only.3mf")
+        b_only = await file_factory(filename="b_only.3mf")
+        ab = await file_factory(filename="ab.3mf")
+        a = (await async_client.post("/api/v1/library/tags", json={"name": "A"})).json()
+        b = (await async_client.post("/api/v1/library/tags", json={"name": "B"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [a_only.id, ab.id], "tag_ids": [a["id"]], "action": "add"},
+        )
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [b_only.id, ab.id], "tag_ids": [b["id"]], "action": "add"},
+        )
+        # Filter by A alone → a_only + ab
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}")
+        assert {x["id"] for x in r.json()} == {a_only.id, ab.id}
+        # Filter by A AND B → only ab
+        r = await async_client.get(f"/api/v1/library/files?tag_ids={a['id']}&tag_ids={b['id']}")
+        assert {x["id"] for x in r.json()} == {ab.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tag_filter_ignores_folder_id(self, async_client: AsyncClient, folder_factory, file_factory):
+        """Tag filter is cross-cutting — passing folder_id must NOT narrow the
+        result. Confirms decision #2 from the design discussion."""
+        folder_a = await folder_factory(name="A")
+        folder_b = await folder_factory(name="B")
+        in_a = await file_factory(folder_id=folder_a.id, filename="in_a.3mf")
+        in_b = await file_factory(folder_id=folder_b.id, filename="in_b.3mf")
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "x"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={
+                "file_ids": [in_a.id, in_b.id],
+                "tag_ids": [tag["id"]],
+                "action": "add",
+            },
+        )
+        # Pass folder_id=folder_a alongside tag_ids — file from folder_b must
+        # STILL appear because the tag filter overrides folder scoping.
+        r = await async_client.get(f"/api/v1/library/files?folder_id={folder_a.id}&tag_ids={tag['id']}")
+        assert {x["id"] for x in r.json()} == {in_a.id, in_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_listing_includes_tags_array(self, async_client: AsyncClient, file_factory):
+        f = await file_factory()
+        tag = (await async_client.post("/api/v1/library/tags", json={"name": "petg"})).json()
+        await async_client.post(
+            "/api/v1/library/tags/bulk-assign",
+            json={"file_ids": [f.id], "tag_ids": [tag["id"]], "action": "add"},
+        )
+        r = await async_client.get("/api/v1/library/files?include_root=false")
+        item = next(x for x in r.json() if x["id"] == f.id)
+        assert item["tags"] == [{"id": tag["id"], "name": "petg"}]

+ 204 - 0
backend/tests/integration/test_local_login_gate.py

@@ -0,0 +1,204 @@
+"""Integration tests for the local login gate + autologin (#1589).
+
+Covers the four contracts described on the GitHub issue:
+1. POST /auth/login rejects local credentials when local_login_enabled=false
+   AND the BAMBUDDY_LOCAL_LOGIN env var is not set.
+2. The BAMBUDDY_LOCAL_LOGIN=true env var bypasses the gate (recovery path).
+3. POST /auth/forgot-password is gated by the same flag (with the same bypass).
+4. GET /auth/advanced-auth/status surfaces both new fields so the LoginPage
+   can render the right UI in a single query.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.settings import Settings
+from backend.app.services.ldap_service import LDAPUserInfo
+
+
+async def _set_setting(db: AsyncSession, key: str, value: str) -> None:
+    result = await db.execute(select(Settings).where(Settings.key == key))
+    row = result.scalar_one_or_none()
+    if row is None:
+        db.add(Settings(key=key, value=value))
+    else:
+        row.value = value
+    await db.commit()
+
+
+async def _enable_auth(async_client: AsyncClient, username: str = "gateadm") -> None:
+    """Set up an auth-enabled install with a known admin so /auth/login is reachable."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": username,
+            "admin_password": "GatePass1!",
+        },
+    )
+
+
+class TestLocalLoginGate:
+    """The `local_login_enabled` setting blocks /auth/login + /auth/forgot-password
+    when the env-var recovery bypass is not in play."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_default_allows_local_credentials(self, async_client: AsyncClient, db_session: AsyncSession):
+        """Default install (setting absent) keeps the pre-#1589 behaviour."""
+        await _enable_auth(async_client, "gatedefault")
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatedefault", "password": "GatePass1!"},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_rejected_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """With local_login_enabled=false and no env bypass, valid creds are
+        rejected with the same generic 401 as bad creds (no UI-stating leak)."""
+        await _enable_auth(async_client, "gatedeny")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatedeny", "password": "GatePass1!"},
+        )
+        assert response.status_code == 401
+        # Same wording as wrong-password 401 — never leaks whether local
+        # login is disabled (would help credential stuffing prioritise).
+        assert "Incorrect username or password" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_var_bypasses_local_disabled_gate(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """BAMBUDDY_LOCAL_LOGIN=true opens the recovery path even when the
+        DB setting forbids local login (SSO-broken admin recovery)."""
+        await _enable_auth(async_client, "gatebypass")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gatebypass", "password": "GatePass1!"},
+        )
+        assert response.status_code == 200, response.text
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_forgot_password_rejected_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """Forgot-password is a local-credentials flow — useless when local
+        login is off (the reset wouldn't grant access anyway)."""
+        await _enable_auth(async_client, "gatefp")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        response = await async_client.post(
+            "/api/v1/auth/forgot-password",
+            json={"email": "x@example.com"},
+        )
+        assert response.status_code == 403
+        assert "Local login is disabled" in response.json()["detail"]
+
+
+class TestLdapLoginNotAffectedByGate:
+    """LDAP keeps its own ldap_enabled switch and bypasses local_login_enabled
+    entirely. This is the regression suite for the refactor in #1589 — without
+    these tests, an LDAP user could fail to log in when local login is
+    disabled even though the gate is supposed to leave LDAP alone."""
+
+    async def _enable_ldap(self, db: AsyncSession) -> None:
+        for key, value in {
+            "ldap_enabled": "true",
+            "ldap_server_url": "ldaps://ldap.test",
+            "ldap_bind_dn": "cn=svc,dc=test,dc=com",
+            "ldap_bind_password": "x",
+            "ldap_search_base": "dc=test,dc=com",
+            "ldap_user_filter": "(uid={username})",
+            "ldap_security": "ldaps",
+            "ldap_group_mapping": "{}",
+            "ldap_auto_provision": "true",
+            "ldap_default_group": "",
+        }.items():
+            await _set_setting(db, key, value)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ldap_login_succeeds_when_local_disabled(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """LDAP-authenticated login must still issue a JWT even when the
+        local-login gate is off and no env-var bypass is set. The original
+        cut of #1589 wiped the LDAP-bound `user` variable in this branch."""
+        await _enable_auth(async_client, "ldapseed")
+        await self._enable_ldap(db_session)
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+
+        fake_ldap = LDAPUserInfo(
+            username="ldapuser",
+            email="ldapuser@test.com",
+            display_name="LDAP User",
+            groups=[],
+        )
+        with patch(
+            "backend.app.services.ldap_service.authenticate_ldap_user",
+            return_value=fake_ldap,
+        ):
+            response = await async_client.post(
+                "/api/v1/auth/login",
+                json={"username": "ldapuser", "password": "anything"},
+            )
+
+        assert response.status_code == 200, response.text
+        assert "access_token" in response.json()
+        assert response.json()["user"]["username"] == "ldapuser"
+
+
+class TestAdvancedAuthStatusSurfacesGate:
+    """The /auth/advanced-auth/status endpoint feeds the LoginPage's render
+    decisions in a single query — it must surface both new #1589 fields."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_includes_local_login_and_autologin(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        monkeypatch.delenv("BAMBUDDY_LOCAL_LOGIN", raising=False)
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        result = response.json()
+        assert "local_login_enabled" in result
+        assert "autologin_provider_id" in result
+        # Default install: local on, no autologin provider.
+        assert result["local_login_enabled"] is True
+        assert result["autologin_provider_id"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_env_var_bypass_flips_status_back_to_true(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """When the DB setting is false but the env-var bypass is set, the
+        status reports local_login_enabled=true so the LoginPage shows the
+        credentials form (matching what the route will actually accept)."""
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "true")
+
+        response = await async_client.get("/api/v1/auth/advanced-auth/status")
+        assert response.status_code == 200
+        assert response.json()["local_login_enabled"] is True

+ 174 - 0
backend/tests/integration/test_locations_api.py

@@ -0,0 +1,174 @@
+"""Integration tests for /inventory/locations (#1004)."""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.location import Location
+from backend.app.services.location_service import assign_location_name
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_locations_crud_and_spool_link(async_client: AsyncClient, db_session: AsyncSession):
+    create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})
+    assert create_resp.status_code == 201
+    loc = create_resp.json()
+    assert loc["name"] == "Shelf A"
+    assert loc["spool_count"] == 0
+
+    dup_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "shelf a"})
+    assert dup_resp.status_code == 409
+
+    spool_resp = await async_client.post(
+        "/api/v1/inventory/spools",
+        json={"material": "PLA", "location_id": loc["id"]},
+    )
+    assert spool_resp.status_code == 200
+    spool = spool_resp.json()
+    assert spool["location_id"] == loc["id"]
+    assert spool["storage_location"] == "Shelf A"
+
+    list_resp = await async_client.get("/api/v1/inventory/locations")
+    assert list_resp.status_code == 200
+    listed = {item["id"]: item for item in list_resp.json()}
+    assert listed[loc["id"]]["spool_count"] == 1
+
+    delete_resp = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
+    assert delete_resp.status_code == 409
+
+    clear_resp = await async_client.patch(
+        f"/api/v1/inventory/spools/{spool['id']}",
+        json={"location_id": None},
+    )
+    assert clear_resp.status_code == 200
+
+    delete_resp2 = await async_client.delete(f"/api/v1/inventory/locations/{loc['id']}")
+    assert delete_resp2.status_code == 200
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rename_location_updates_spool_count(async_client: AsyncClient):
+    create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Old Name"})
+    loc = create_resp.json()
+
+    await async_client.post(
+        "/api/v1/inventory/spools",
+        json={"material": "PLA", "location_id": loc["id"]},
+    )
+
+    list_before = await async_client.get("/api/v1/inventory/locations")
+    by_id = {item["id"]: item for item in list_before.json()}
+    assert by_id[loc["id"]]["spool_count"] == 1
+
+    rename_resp = await async_client.patch(
+        f"/api/v1/inventory/locations/{loc['id']}",
+        json={"name": "New Name"},
+    )
+    assert rename_resp.status_code == 200
+    assert rename_resp.json()["name"] == "New Name"
+    assert rename_resp.json()["spool_count"] == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_rename_location_collision_returns_409(async_client: AsyncClient):
+    first = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf A"})
+    second = await async_client.post("/api/v1/inventory/locations", json={"name": "Shelf B"})
+    assert first.status_code == 201
+    assert second.status_code == 201
+
+    collision = await async_client.patch(
+        f"/api/v1/inventory/locations/{second.json()['id']}",
+        json={"name": "Shelf A"},
+    )
+    assert collision.status_code == 409
+    assert collision.json()["detail"] == "A location with this name already exists"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_create_location_duplicate_after_commit_returns_409(async_client: AsyncClient):
+    """Second create with the same name_key must return 409, not 500."""
+    first = await async_client.post("/api/v1/inventory/locations", json={"name": "Race Shelf"})
+    second = await async_client.post("/api/v1/inventory/locations", json={"name": "race shelf"})
+    assert first.status_code == 201
+    assert second.status_code == 409
+    assert second.json()["detail"] == "A location with this name already exists"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_list_locations_is_read_only(async_client: AsyncClient, db_session: AsyncSession):
+    """GET /locations is a pure read — no catalog rows appear without explicit writes."""
+    from sqlalchemy import func, select
+
+    loc = Location()
+    assign_location_name(loc, "Local Only")
+    db_session.add(loc)
+    await db_session.commit()
+
+    before = await db_session.scalar(select(func.count()).select_from(Location))
+    resp = await async_client.get("/api/v1/inventory/locations")
+    after = await db_session.scalar(select(func.count()).select_from(Location))
+
+    assert resp.status_code == 200
+    assert len(resp.json()) == 1
+    assert before == after == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_update_location_404_on_unknown_id(async_client: AsyncClient):
+    resp = await async_client.patch(
+        "/api/v1/inventory/locations/99999",
+        json={"name": "Ghost"},
+    )
+    assert resp.status_code == 404
+    assert resp.json()["detail"] == "Location not found"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_delete_location_404_on_unknown_id(async_client: AsyncClient):
+    resp = await async_client.delete("/api/v1/inventory/locations/99999")
+    assert resp.status_code == 404
+    assert resp.json()["detail"] == "Location not found"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_locations_routes_require_auth_when_enabled(async_client: AsyncClient):
+    """All five /locations endpoints must return 401 when auth is enabled and
+    no credentials are presented. Mirror of the pattern from
+    test_queue_start_user_attribution._enable_auth_with_admin — required by
+    project policy: every permission-gated route gets a fail-closed test on
+    first ship, no follow-ups (the two CVSS 9.8/9.9 advisories shipped from
+    this exact gap)."""
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "locations1505admin",
+            "admin_password": "AdminPass1!",
+        },
+    )
+
+    # GET /locations — read-gated
+    list_resp = await async_client.get("/api/v1/inventory/locations")
+    assert list_resp.status_code == 401, list_resp.text
+
+    # POST /locations — write-gated
+    create_resp = await async_client.post("/api/v1/inventory/locations", json={"name": "Locked"})
+    assert create_resp.status_code == 401, create_resp.text
+
+    # PATCH /locations/{id} — write-gated. Use a synthetic id; the auth gate
+    # runs before the not-found check, so 401 is the correct expectation even
+    # when the id doesn't exist.
+    patch_resp = await async_client.patch("/api/v1/inventory/locations/99999", json={"name": "Locked2"})
+    assert patch_resp.status_code == 401, patch_resp.text
+
+    # DELETE /locations/{id} — write-gated
+    delete_resp = await async_client.delete("/api/v1/inventory/locations/99999")
+    assert delete_resp.status_code == 401, delete_resp.text

+ 302 - 0
backend/tests/integration/test_makerworld_apikey_auth.py

@@ -0,0 +1,302 @@
+"""Integration tests for #1777 — API-keyed callers on /makerworld/*.
+
+The contract being pinned (mirrors the slice path's #1182 follow-up):
+
+  When auth is enabled and the request carries an X-API-Key whose owner
+  has a stored Bambu Cloud token, the makerworld routes must resolve
+  identity via ``resolve_api_key_cloud_owner`` (instead of always seeing
+  ``current_user=None``) so:
+
+  - /status reports ``has_cloud_token=True`` for keys whose owner has a token
+  - /resolve builds a MakerWorldService seeded with that token
+  - /import succeeds end-to-end and attributes the resulting LibraryFile
+    to the API-key owner
+
+  The fail-closed path is preserved: keys without ``can_access_cloud=True``
+  still surface the "requires Bambu Cloud login" experience (no auth gap).
+"""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.library import LibraryFile
+from backend.app.models.user import User
+
+
+async def _setup_auth_with_admin(client: AsyncClient) -> str:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={
+            "auth_enabled": True,
+            "admin_username": "mwadmin",
+            "admin_password": "AdminPass1!",
+        },
+    )
+    login = await client.post(
+        "/api/v1/auth/login",
+        json={"username": "mwadmin", "password": "AdminPass1!"},
+    )
+    return login.json()["access_token"]
+
+
+async def _store_admin_cloud_token(db: AsyncSession, username: str, token: str) -> User:
+    result = await db.execute(select(User).where(User.username == username))
+    user = result.scalar_one()
+    user.cloud_token = token
+    user.cloud_email = "owner@example.com"
+    user.cloud_region = "global"
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+async def _make_key(
+    db: AsyncSession,
+    *,
+    owner: User,
+    name: str,
+    can_access_cloud: bool = True,
+    can_read_status: bool = True,
+    can_manage_library: bool = True,
+) -> str:
+    """Mint an API key with the scopes /makerworld/* expects.
+
+    /status + /resolve gate on ``Permission.MAKERWORLD_VIEW`` which maps
+    to the ``can_read_status`` scope (see ``_APIKEY_SCOPE_BY_PERMISSION``
+    in core/auth.py). /import gates on ``Permission.MAKERWORLD_IMPORT``
+    which maps to ``can_manage_library``. ``can_access_cloud`` is what
+    ``resolve_api_key_cloud_owner`` checks before returning the owner —
+    the separate field this PR's fix actually depends on.
+    """
+    full_key, key_hash, key_prefix = generate_api_key()
+    row = APIKey(
+        name=name,
+        key_hash=key_hash,
+        key_prefix=key_prefix,
+        user_id=owner.id,
+        can_access_cloud=can_access_cloud,
+        can_read_status=can_read_status,
+        can_manage_library=can_manage_library,
+    )
+    db.add(row)
+    await db.commit()
+    return full_key
+
+
+def _fake_service(**stubs):
+    """Mirror of the fixture in test_makerworld_routes.py — AsyncMock with
+    method stubs that return the supplied payloads."""
+    svc = AsyncMock()
+    svc.close = AsyncMock()
+    for name, value in stubs.items():
+        if callable(value) and not isinstance(value, AsyncMock):
+            setattr(svc, name, AsyncMock(side_effect=value))
+        else:
+            setattr(svc, name, AsyncMock(return_value=value))
+    return svc
+
+
+class TestStatusEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_owner_with_token_sees_has_cloud_token_true(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        await _setup_auth_with_admin(async_client)
+        admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
+        key = await _make_key(db_session, owner=admin, name="status-cloud")
+
+        resp = await async_client.get(
+            "/api/v1/makerworld/status",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 200, resp.text
+        assert resp.json() == {"has_cloud_token": True, "can_download": True}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_without_cloud_scope_reports_no_token(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Key has the per-route scope (can_read_status) but NOT can_access_cloud.
+
+        Before this PR, both these conditions reported has_cloud_token=False.
+        After the PR the per-route scope alone still doesn't grant cloud
+        access — the resolver fences on can_access_cloud — so the response
+        is unchanged for this case. Pinning so a future change can't
+        accidentally widen the gate.
+        """
+        await _setup_auth_with_admin(async_client)
+        admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
+        key = await _make_key(db_session, owner=admin, name="status-no-cloud", can_access_cloud=False)
+
+        resp = await async_client.get(
+            "/api/v1/makerworld/status",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 200
+        assert resp.json() == {"has_cloud_token": False, "can_download": False}
+
+
+class TestResolveEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_owner_with_token_builds_authed_service(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """The route must reach ``_build_service`` with the API-key owner's
+        User, which is what ultimately seeds MakerWorldService.auth_token.
+        We assert on the resolved user argument the route passes through —
+        the upstream MakerWorld API call is mocked so the test stays offline.
+        """
+        await _setup_auth_with_admin(async_client)
+        admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
+        key = await _make_key(db_session, owner=admin, name="resolve-cloud")
+
+        design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
+        instances = {"total": 0, "hits": []}
+        svc = _fake_service(get_design=design, get_design_instances=instances)
+        build = AsyncMock(return_value=svc)
+
+        with patch("backend.app.api.routes.makerworld._build_service", build):
+            resp = await async_client.post(
+                "/api/v1/makerworld/resolve",
+                json={"url": "https://makerworld.com/en/models/1400373"},
+                headers={"X-API-Key": key},
+            )
+        assert resp.status_code == 200, resp.text
+        # _build_service receives (db, user); the user arg must be the owning admin.
+        # Without the fix it'd be None (the API-key dep value).
+        assert build.await_count == 1
+        passed_user = (
+            build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
+        )
+        assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
+        assert passed_user.id == admin.id
+
+
+class TestImportEndpoint:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_owner_import_succeeds_and_stamps_owner_id(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """End-to-end: /import via X-API-Key downloads the 3MF and saves it
+        with the API-key owner's id on the LibraryFile row, not NULL."""
+        await _setup_auth_with_admin(async_client)
+        admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
+        key = await _make_key(db_session, owner=admin, name="import-cloud")
+
+        design = {
+            "id": 1400373,
+            "modelId": "US2bb73b106683e5",
+            "title": "Cube",
+            "instances": [{"profileId": 298919107, "title": "default"}],
+        }
+        manifest = {
+            "name": "cube.3mf",
+            "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
+        }
+        # 3MF download returns (bytes, filename). The bytes don't have to be a
+        # valid zip — save_3mf_bytes_to_library stores them as-is and the
+        # downstream thumbnail extractor swallows errors.
+        svc = _fake_service(
+            get_design=design,
+            get_profile_download=manifest,
+            download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373},
+                headers={"X-API-Key": key},
+            )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+        assert body["was_existing"] is False
+
+        # The library row was attributed to the API-key owner.
+        # save_3mf_bytes_to_library translates owner_id → created_by_id on the
+        # LibraryFile column (see library.py:534).
+        result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
+        saved = result.scalar_one()
+        assert saved.created_by_id == admin.id, "Import via API key must attribute the row to the key's owner, not NULL"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_without_cloud_scope_still_imports_but_owner_is_none(
+        self, async_client: AsyncClient, db_session: AsyncSession
+    ):
+        """Fail-closed parity: a key with can_manage_library but NOT
+        can_access_cloud reaches the route (permission gate passes) but
+        the cloud-token resolver returns None, so the service is built
+        without a token. The MakerWorldService itself would 401 on
+        get_profile_download in production — here we just confirm the
+        route doesn't suddenly grant cloud identity from a non-cloud key,
+        and that the library row's owner_id stays NULL when there's no
+        resolved cloud-scoped owner.
+        """
+        await _setup_auth_with_admin(async_client)
+        admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
+        key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
+
+        design = {
+            "id": 1400373,
+            "modelId": "US2bb73b106683e5",
+            "instances": [{"profileId": 298919107}],
+        }
+        manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
+        svc = _fake_service(
+            get_design=design,
+            get_profile_download=manifest,
+            download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
+        )
+
+        with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
+            resp = await async_client.post(
+                "/api/v1/makerworld/import",
+                json={"model_id": 1400373},
+                headers={"X-API-Key": key},
+            )
+        assert resp.status_code == 200, resp.text
+        body = resp.json()
+
+        # _build_service got None — same as before the PR for non-cloud keys.
+        passed_user = (
+            build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
+        )
+        assert passed_user is None
+
+        # And owner_id is NULL because the cloud-scope fence said no.
+        result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
+        saved = result.scalar_one()
+        assert saved.created_by_id is None
+
+
+class TestJwtPathUnchanged:
+    """Parity check — the existing JWT-authed flow must keep behaving as
+    it did. The added Depends(resolve_api_key_cloud_owner) returns None
+    for JWT callers so current_user from RequirePermissionIfAuthEnabled
+    wins the ``or`` and nothing about the JWT path changes."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_status_with_jwt_admin_token(self, async_client: AsyncClient, db_session: AsyncSession):
+        admin_token = await _setup_auth_with_admin(async_client)
+        await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
+
+        resp = await async_client.get(
+            "/api/v1/makerworld/status",
+            headers={"Authorization": f"Bearer {admin_token}"},
+        )
+        assert resp.status_code == 200
+        assert resp.json() == {"has_cloud_token": True, "can_download": True}

+ 251 - 3
backend/tests/integration/test_ownership_permissions.py

@@ -287,15 +287,15 @@ class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
         assert response.status_code == 403
 
     # ========================================================================
-    # REPRINT permissions
+    # Legacy reprint endpoint
     # ========================================================================
 
     @pytest.mark.asyncio
     @pytest.mark.integration
-    async def test_operator_cannot_reprint_others_archive(
+    async def test_reprint_endpoint_is_gone_for_all_callers(
         self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
     ):
-        """Operator cannot reprint another user's archive."""
+        """Direct archive reprint no longer exists; callers must use the queue."""
         printer = await printer_factory()
         archive = await archive_factory(
             printer.id,
@@ -307,7 +307,122 @@ class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
             headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
         )
 
+        assert response.status_code == 410
+
+    # ========================================================================
+    # Queue route — archives:reprint_* gate (#1625)
+    # ========================================================================
+    # The unified /queue/ route replaced the legacy /reprint endpoint; the
+    # reprint permission gate must move with it. Without these checks a
+    # caller with QUEUE_CREATE + ARCHIVES_READ_OWN could reprint their own
+    # archives even if explicitly denied ARCHIVES_REPRINT_OWN.
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_route_operator_can_reprint_own_archive(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Operator with REPRINT_OWN can queue their own archive."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            created_by_id=auth_setup["operator_user"]["id"],
+        )
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"printer_id": printer.id, "archive_id": archive.id},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_route_user_without_reprint_gets_403(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """User with QUEUE_CREATE + ARCHIVES_READ_OWN but no reprint perm → 403.
+
+        Custom group mirrors a real operator policy where someone is allowed
+        to enqueue freshly-uploaded library files but explicitly NOT allowed
+        to re-run completed archives.
+        """
+        # Create custom group with queue:create + archives:read_own but no reprint perm.
+        admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+        group_resp = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={
+                "name": "QueueOnlyNoReprint",
+                "description": "Test group: can queue library files but not reprint",
+                "permissions": [
+                    "queue:create",
+                    "queue:read_own",
+                    "archives:read_own",
+                    "library:read_own",
+                    "library:upload",
+                    "printers:read",
+                ],
+            },
+        )
+        assert group_resp.status_code in (200, 201)
+        group_id = group_resp.json()["id"]
+
+        await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={
+                "username": "noreprint_user",
+                "password": "NoreprintPass1!",
+                "group_ids": [group_id],
+            },
+        )
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "noreprint_user", "password": "NoreprintPass1!"},
+        )
+        token = login.json()["access_token"]
+        user_id = login.json()["user"]["id"]
+
+        # Archive owned by the no-reprint user.
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=user_id)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {token}"},
+            json={"printer_id": printer.id, "archive_id": archive.id},
+        )
+
         assert response.status_code == 403
+        assert "reprint" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_route_ownerless_archive_requires_reprint_all(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Ownerless archive (created_by_id=null) requires REPRINT_ALL.
+
+        Pre-IDOR-fix legacy data has no creator; an operator with
+        REPRINT_OWN can't fall back to "I own this" — fail-closed.
+        The existing IDOR check returns 404 first (operator lacks
+        READ_ALL and doesn't own the row), so this is also a regression
+        guard against accidentally surfacing 403-instead-of-404 if the
+        IDOR check is ever loosened.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=None)
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+            json={"printer_id": printer.id, "archive_id": archive.id},
+        )
+
+        # IDOR returns 404 before the new gate fires for this operator.
+        assert response.status_code == 404
 
 
 class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):
@@ -426,6 +541,139 @@ class TestQueueOwnershipPermissions(TestOwnershipPermissionsSetup):
 
         assert response.status_code == 403
 
+    # ========================================================================
+    # Start / Stop ownership gates (#1625-followup)
+    # ========================================================================
+    # Pre-fix /stop required QUEUE_UPDATE_ALL (admin-only) — operators saw the
+    # Stop button in the queue UI but got 403 on click. /start required
+    # QUEUE_UPDATE_OWN with no ownership check — operators could start anyone's
+    # queue items via direct API. Both now use require_ownership_permission.
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_start_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Operator can start their own staged queue item."""
+        item = await queue_item_factory(
+            created_by_id=auth_setup["operator_user"]["id"],
+            manual_start=True,
+        )
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_start_others_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory
+    ):
+        """Operator cannot start another user's queue item."""
+        item = await queue_item_factory(
+            created_by_id=auth_setup["operator2_user"]["id"],
+            manual_start=True,
+        )
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_start_unowned_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory, db_session
+    ):
+        """Operator can start a NULL-owner queue item (VP-uploaded, #1670)
+        and claims ownership in the process.
+
+        Stop and Cancel reject unowned items for _OWN holders (destructive,
+        no "I own it" claim available), but Start is the entry point for the
+        VP-import flow where attribution happens at click-time.
+        """
+        from backend.app.models.print_queue import PrintQueueItem
+
+        item = await queue_item_factory(created_by_id=None, manual_start=True)
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/start?skip_filament_check=true",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+        # Ownership claimed: operator is now the item's owner.
+        await db_session.refresh(item)
+        refetch = await db_session.get(PrintQueueItem, item.id)
+        assert refetch.created_by_id == auth_setup["operator_user"]["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_can_stop_own_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Operator can stop their own currently-printing queue item."""
+        item = await queue_item_factory(
+            created_by_id=auth_setup["operator_user"]["id"],
+            status="printing",
+        )
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/stop",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_stop_others_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory
+    ):
+        """Operator cannot stop another user's printing queue item."""
+        item = await queue_item_factory(
+            created_by_id=auth_setup["operator2_user"]["id"],
+            status="printing",
+        )
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/stop",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_cannot_stop_unowned_queue_item(
+        self, async_client: AsyncClient, auth_setup, queue_item_factory
+    ):
+        """Operator cannot stop a NULL-owner printing queue item — stop mirrors
+        cancel (destructive, no claim semantics). Admins with _ALL can still stop it.
+        """
+        item = await queue_item_factory(created_by_id=None, status="printing")
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/stop",
+            headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
+        )
+
+        assert response.status_code == 403
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_can_stop_any_queue_item(self, async_client: AsyncClient, auth_setup, queue_item_factory):
+        """Admin with _ALL can stop any printing queue item including unowned."""
+        item = await queue_item_factory(created_by_id=None, status="printing")
+
+        response = await async_client.post(
+            f"/api/v1/queue/{item.id}/stop",
+            headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
+        )
+
+        assert response.status_code == 200
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_bulk_update_skips_non_owned_items(self, async_client: AsyncClient, auth_setup, queue_item_factory):

+ 706 - 0
backend/tests/integration/test_print_queue_api.py

@@ -1893,6 +1893,199 @@ class TestAbortedStatusNormalisation:
         positions = [i["position"] for i in batch_items]
         assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_shifts_existing_items(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Verify priority insertion shifts existing pending items in the same printer queue."""
+        printer = await printer_factory()
+        first = await archive_factory(print_name="First")
+        second = await archive_factory(print_name="Second")
+        priority = await archive_factory(print_name="Priority")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
+        ).status_code == 200
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
+        ).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": priority.id,
+                "insert_position": 1,
+            },
+        )
+        assert response.status_code == 200
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items[:3]] == [priority.id, first.id, second.id]
+        assert [item["position"] for item in items[:3]] == [1, 2, 3]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_quantity_shifts_existing_by_quantity(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """ASAP batch insertion shifts existing pending items by the inserted quantity."""
+        printer = await printer_factory()
+        first = await archive_factory(print_name="First")
+        second = await archive_factory(print_name="Second")
+        priority = await archive_factory(print_name="Priority")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
+        ).status_code == 200
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
+        ).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": priority.id,
+                "quantity": 3,
+                "insert_position": 1,
+            },
+        )
+        assert response.status_code == 200
+        batch_id = response.json()["batch_id"]
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items] == [
+            priority.id,
+            priority.id,
+            priority.id,
+            first.id,
+            second.id,
+        ]
+        assert [item["position"] for item in items] == [1, 2, 3, 4, 5]
+        assert [item["batch_id"] for item in items[:3]] == [batch_id, batch_id, batch_id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_scopes_unassigned_items(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Unassigned inserts shift only the unassigned queue scope."""
+        printer = await printer_factory()
+        unassigned_first = await archive_factory(print_name="Unassigned First")
+        unassigned_second = await archive_factory(print_name="Unassigned Second")
+        assigned = await archive_factory(print_name="Assigned")
+        priority = await archive_factory(print_name="Unassigned Priority")
+
+        assert (await async_client.post("/api/v1/queue/", json={"archive_id": unassigned_first.id})).status_code == 200
+        assert (await async_client.post("/api/v1/queue/", json={"archive_id": unassigned_second.id})).status_code == 200
+        assigned_response = await async_client.post(
+            "/api/v1/queue/",
+            json={"printer_id": printer.id, "archive_id": assigned.id},
+        )
+        assert assigned_response.status_code == 200
+        assert assigned_response.json()["position"] == 1
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "archive_id": priority.id,
+                "insert_position": 1,
+            },
+        )
+        assert response.status_code == 200
+
+        unassigned_response = await async_client.get("/api/v1/queue/?printer_id=-1")
+        unassigned_items = sorted(unassigned_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in unassigned_items] == [
+            priority.id,
+            unassigned_first.id,
+            unassigned_second.id,
+        ]
+        assert [item["position"] for item in unassigned_items] == [1, 2, 3]
+
+        assigned_scope_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}&target_model=NONE")
+        assigned_items = sorted(assigned_scope_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in assigned_items] == [assigned.id]
+        assert [item["position"] for item in assigned_items] == [1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_insert_position_greater_than_max_appends_without_gap(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Oversized explicit insert_position appends at max+1 instead of creating sparse positions."""
+        printer = await printer_factory()
+        first = await archive_factory(print_name="First")
+        second = await archive_factory(print_name="Second")
+        appended = await archive_factory(print_name="Append")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
+        ).status_code == 200
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
+        ).status_code == 200
+
+        response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": appended.id,
+                "insert_position": 99,
+            },
+        )
+        assert response.status_code == 200
+        assert response.json()["position"] == 3
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items] == [first.id, second.id, appended.id]
+        assert [item["position"] for item in items] == [1, 2, 3]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_consecutive_asap_inserts_stack_in_submission_order(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Consecutive ASAP inserts to the same printer preserve the client submission order."""
+        printer = await printer_factory()
+        existing = await archive_factory(print_name="Existing")
+        first_asap = await archive_factory(print_name="First ASAP")
+        second_asap = await archive_factory(print_name="Second ASAP")
+
+        assert (
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": existing.id})
+        ).status_code == 200
+
+        first_response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": first_asap.id,
+                "insert_position": 1,
+            },
+        )
+        assert first_response.status_code == 200
+
+        second_response = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": second_asap.id,
+                "insert_position": 2,
+            },
+        )
+        assert second_response.status_code == 200
+
+        list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
+        items = sorted(list_response.json(), key=lambda item: item["position"])
+        assert [item["archive_id"] for item in items] == [first_asap.id, second_asap.id, existing.id]
+        assert [item["position"] for item in items] == [1, 2, 3]
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_add_to_queue_quantity_with_print_options(
@@ -2002,6 +2195,152 @@ class TestAbortedStatusNormalisation:
         response = await async_client.get("/api/v1/queue/batches/9999")
         assert response.status_code == 404
 
+    # ========================================================================
+    # Queue redesign: create-empty + group-existing + ungroup
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_empty_batch_for_client_side_grouping(
+        self, async_client: AsyncClient, printer_factory, archive_factory
+    ):
+        """Verify POST /queue/batches without item_ids creates an empty batch
+        whose id can be passed on subsequent /queue/ POSTs (the multi-plate
+        auto-batch flow). Subsequent items must end up with the same batch_id."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # 1. Pre-create batch
+        batch_resp = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Plates · 2 plates", "archive_id": archive.id},
+        )
+        assert batch_resp.status_code == 200
+        batch = batch_resp.json()
+        assert batch["status"] == "active"
+        batch_id = batch["id"]
+
+        # 2. Add two items referencing that batch
+        for plate_id in (1, 2):
+            item_resp = await async_client.post(
+                "/api/v1/queue/",
+                json={
+                    "printer_id": printer.id,
+                    "archive_id": archive.id,
+                    "plate_id": plate_id,
+                    "batch_id": batch_id,
+                },
+            )
+            assert item_resp.status_code == 200
+            assert item_resp.json()["batch_id"] == batch_id
+
+        # 3. Verify batch now has 2 pending children
+        list_resp = await async_client.get("/api/v1/queue/")
+        siblings = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert len(siblings) == 2
+        assert {i["plate_id"] for i in siblings} == {1, 2}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_group_existing_items_as_batch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
+    ):
+        """Verify POST /queue/batches with item_ids assigns batch_id to
+        existing pending items (the 'Group as batch' UI action)."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        item_a = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+        item_b = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+
+        resp = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Manual group", "item_ids": [item_a.id, item_b.id]},
+        )
+        assert resp.status_code == 200
+        batch_id = resp.json()["id"]
+
+        list_resp = await async_client.get("/api/v1/queue/")
+        grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert {i["id"] for i in grouped} == {item_a.id, item_b.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_group_skips_non_pending_items(
+        self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
+    ):
+        """Verify grouping doesn't pull in already-completed/cancelled items."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
+        completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
+
+        resp = await async_client.post(
+            "/api/v1/queue/batches",
+            json={"name": "Mixed", "item_ids": [pending.id, completed.id]},
+        )
+        assert resp.status_code == 200
+        batch_id = resp.json()["id"]
+
+        list_resp = await async_client.get("/api/v1/queue/")
+        grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert {i["id"] for i in grouped} == {pending.id}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_batch_requires_name(self, async_client: AsyncClient):
+        """Verify empty / whitespace-only name is rejected with 400."""
+        resp = await async_client.post("/api/v1/queue/batches", json={"name": "   "})
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ungroup_batch_clears_batch_id_and_deletes_row(
+        self, async_client: AsyncClient, printer_factory, archive_factory
+    ):
+        """Verify POST /queue/batches/{id}/ungroup clears batch_id from all
+        members and deletes the batch row when nothing remains assigned."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+
+        # Create batch with two items via the existing quantity flow
+        add_resp = await async_client.post(
+            "/api/v1/queue/",
+            json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2},
+        )
+        batch_id = add_resp.json()["batch_id"]
+
+        # Ungroup
+        ungroup_resp = await async_client.post(f"/api/v1/queue/batches/{batch_id}/ungroup")
+        assert ungroup_resp.status_code == 200
+        assert ungroup_resp.json()["ungrouped_count"] == 2
+
+        # Verify items still exist but no longer batched
+        list_resp = await async_client.get("/api/v1/queue/")
+        ex_members = [i for i in list_resp.json() if i["batch_id"] == batch_id]
+        assert ex_members == []
+
+        # Batch row was deleted
+        get_resp = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
+        assert get_resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_with_unknown_batch_id_404(
+        self, async_client: AsyncClient, printer_factory, archive_factory
+    ):
+        """Verify addToQueue with a non-existent batch_id is rejected."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        resp = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "printer_id": printer.id,
+                "archive_id": archive.id,
+                "batch_id": 99999,
+            },
+        )
+        assert resp.status_code == 404
+
     # ========================================================================
     # Soft-deleted archive handling (#1348 follow-up)
     # ========================================================================
@@ -2101,3 +2440,370 @@ class TestAbortedStatusNormalisation:
         assert row["archive_deleted"] is False
         assert row["archive_name"] == "Live Archive"
         assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
+
+
+class TestResumeQueueAfterFailure:
+    """Integration tests for POST /api/v1/queue/printer/{id}/resume (#1818)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_printer(**kwargs):
+            from backend.app.models.printer import Printer
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "name": f"Resume Printer {counter}",
+                "ip_address": f"192.168.42.{100 + counter}",
+                "serial_number": f"RESUMESERIAL{counter:04d}",
+                "access_code": "12345678",
+                "model": "P1S",
+            }
+            defaults.update(kwargs)
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create_printer
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        _counter = [0]
+
+        async def _create_archive(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            counter = _counter[0]
+            defaults = {
+                "filename": f"resume_print_{counter}.3mf",
+                "print_name": f"Resume Print {counter}",
+                "file_path": f"/tmp/resume_print_{counter}.3mf",  # nosec B108
+                "file_size": 1024,
+                "content_hash": f"resumehash{counter:08d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create_archive
+
+    async def _add_item(self, db_session, printer, archive_factory, **kwargs):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        archive = await archive_factory()
+        defaults = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "status": "pending",
+            "require_previous_success": True,
+        }
+        defaults.update(kwargs)
+        item = PrintQueueItem(**defaults)
+        db_session.add(item)
+        await db_session.commit()
+        await db_session.refresh(item)
+        return item
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_unknown_printer_returns_404(self, async_client: AsyncClient):
+        resp = await async_client.post("/api/v1/queue/printer/999999/resume")
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_no_op_on_clean_queue(self, async_client: AsyncClient, printer_factory):
+        """Calling resume on a printer with no failures and no skipped items
+        returns zero counts — endpoint is idempotent and safe to spam."""
+        printer = await printer_factory()
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.status_code == 200
+        assert resp.json() == {"acknowledged": 0, "restored": 0}
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_acknowledges_failed_and_restores_skipped(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Reporter's scenario: failed predecessor + N skipped downstream items.
+        Resume sets gate_acknowledged on the failure and flips skipped → pending."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        failed = await self._add_item(db_session, printer, archive_factory, status="failed")
+        skipped_1 = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+        skipped_2 = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.status_code == 200
+        assert resp.json() == {"acknowledged": 1, "restored": 2}
+
+        failed_id = failed.id
+        skipped_ids = [skipped_1.id, skipped_2.id]
+        db_session.expire_all()
+
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_id))
+        assert result.scalar_one().gate_acknowledged is True
+
+        for sid in skipped_ids:
+            result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == sid))
+            row = result.scalar_one()
+            assert row.status == "pending"
+            assert row.error_message is None
+            assert row.completed_at is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_preserves_skipped_items_with_other_reasons(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Skipped items whose error_message is something OTHER than the
+        gate string (e.g. filament-deficit promotion, future skip reasons)
+        must not be touched — they encode different user intent."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        gate_skip = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+        other_skip = await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="User skipped via UI",
+        )
+
+        gate_id = gate_skip.id
+        other_id = other_skip.id
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.json() == {"acknowledged": 0, "restored": 1}
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == gate_id))
+        assert result.scalar_one().status == "pending"
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == other_id))
+        assert result.scalar_one().status == "skipped"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_scoped_to_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A resume on printer A must not clear printer B's gate — farms run
+        each printer's queue independently."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        p1 = await printer_factory()
+        p2 = await printer_factory()
+        failed_p1 = await self._add_item(db_session, p1, archive_factory, status="failed")
+        failed_p2 = await self._add_item(db_session, p2, archive_factory, status="failed")
+
+        failed_p1_id = failed_p1.id
+        failed_p2_id = failed_p2.id
+        resp = await async_client.post(f"/api/v1/queue/printer/{p1.id}/resume")
+        assert resp.json() == {"acknowledged": 1, "restored": 0}
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p1_id))
+        assert result.scalar_one().gate_acknowledged is True
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p2_id))
+        assert result.scalar_one().gate_acknowledged is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_handles_aborted_status(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Aborted prints (printer-detected mid-print failure) gate the same
+        way failed prints do and must also be acknowledgeable."""
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        aborted = await self._add_item(db_session, printer, archive_factory, status="aborted")
+        aborted_id = aborted.id
+        resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert resp.json() == {"acknowledged": 1, "restored": 0}
+
+        db_session.expire_all()
+        result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == aborted_id))
+        assert result.scalar_one().gate_acknowledged is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resume_idempotent_second_call_is_no_op(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Calling resume twice on the same printer doesn't re-acknowledge
+        the same failure — the second call sees acknowledged=0, restored=0."""
+        printer = await printer_factory()
+        await self._add_item(db_session, printer, archive_factory, status="failed")
+        await self._add_item(
+            db_session,
+            printer,
+            archive_factory,
+            status="skipped",
+            error_message="Previous print failed or was aborted",
+        )
+
+        first = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert first.json() == {"acknowledged": 1, "restored": 1}
+
+        second = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
+        assert second.json() == {"acknowledged": 0, "restored": 0}
+
+
+class TestReorderEndpoint:
+    """Tests for the /queue/reorder endpoint (#1625-followup duplicate-position validator)."""
+
+    @pytest.fixture
+    async def printer_factory(self, db_session):
+        async def _create(**kwargs):
+            from backend.app.models.printer import Printer
+
+            defaults = {
+                "name": "Reorder Test Printer",
+                "ip_address": "192.168.1.220",
+                "serial_number": "TESTREORDER001",
+                "access_code": "12345678",
+                "model": "X1C",
+            }
+            defaults.update(kwargs)
+            printer = Printer(**defaults)
+            db_session.add(printer)
+            await db_session.commit()
+            await db_session.refresh(printer)
+            return printer
+
+        return _create
+
+    @pytest.fixture
+    async def archive_factory(self, db_session):
+        _counter = [0]
+
+        async def _create(**kwargs):
+            from backend.app.models.archive import PrintArchive
+
+            _counter[0] += 1
+            defaults = {
+                "filename": f"reorder_{_counter[0]}.3mf",
+                "print_name": f"Reorder {_counter[0]}",
+                "file_path": f"/tmp/reorder_{_counter[0]}.3mf",  # nosec B108
+                "file_size": 1024,
+                "content_hash": f"reorderhash{_counter[0]:06d}",
+                "status": "completed",
+            }
+            defaults.update(kwargs)
+            archive = PrintArchive(**defaults)
+            db_session.add(archive)
+            await db_session.commit()
+            await db_session.refresh(archive)
+            return archive
+
+        return _create
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reorder_rejects_duplicate_positions(
+        self, async_client: AsyncClient, db_session, printer_factory, archive_factory
+    ):
+        """Reorder payload with duplicate positions → 422 at schema layer.
+
+        Regression guard: pre-fix, a buggy client sending two items at the
+        same position would leave the queue in an inconsistent state (the
+        scheduler's ORDER BY (printer_id, position) tie would be broken by
+        physical row order — non-deterministic dispatch order).
+        """
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        a1 = await archive_factory()
+        a2 = await archive_factory()
+        item1 = PrintQueueItem(printer_id=printer.id, archive_id=a1.id, status="pending", position=1)
+        item2 = PrintQueueItem(printer_id=printer.id, archive_id=a2.id, status="pending", position=2)
+        db_session.add_all([item1, item2])
+        await db_session.commit()
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+
+        response = await async_client.post(
+            "/api/v1/queue/reorder",
+            json={
+                "items": [
+                    {"id": item1.id, "position": 1},
+                    {"id": item2.id, "position": 1},  # duplicate
+                ]
+            },
+        )
+        assert response.status_code == 422
+        body = response.json()
+        # Pydantic v2 wraps custom validator errors; the message must mention "Duplicate"
+        # so the FE can surface the actionable detail.
+        assert any("duplicate" in str(err).lower() for err in body.get("detail", []))
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reorder_accepts_unique_positions(
+        self, async_client: AsyncClient, db_session, printer_factory, archive_factory
+    ):
+        """Reorder with unique positions succeeds and updates them in DB."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        a1 = await archive_factory()
+        a2 = await archive_factory()
+        item1 = PrintQueueItem(printer_id=printer.id, archive_id=a1.id, status="pending", position=1)
+        item2 = PrintQueueItem(printer_id=printer.id, archive_id=a2.id, status="pending", position=2)
+        db_session.add_all([item1, item2])
+        await db_session.commit()
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+
+        response = await async_client.post(
+            "/api/v1/queue/reorder",
+            json={
+                "items": [
+                    {"id": item1.id, "position": 2},
+                    {"id": item2.id, "position": 1},
+                ]
+            },
+        )
+        assert response.status_code == 200
+
+        await db_session.refresh(item1)
+        await db_session.refresh(item2)
+        assert item1.position == 2
+        assert item2.position == 1

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

@@ -1729,6 +1729,187 @@ class TestClearHMSErrorsAPI:
             assert "failed" in response.json()["detail"].lower()
 
 
+class TestExecuteHMSActionAPI:
+    """Integration tests for the /hms/execute-action endpoint (#1743).
+
+    Mirrors TestClearHMSErrorsAPI's shape — the two routes share the same
+    permission gate, the same DB-lookup + client-existence flow, and the
+    same dispatch-then-return-success pattern. The body-validation cases
+    add coverage that the bare clear endpoint doesn't need.
+    """
+
+    _VALID_BODY = {
+        "print_error": "03008070",
+        "action": "OK_BUTTON",
+        "job_id": None,
+    }
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_not_found(self, async_client: AsyncClient):
+        """404 for a printer id that doesn't exist."""
+        response = await async_client.post("/api/v1/printers/99999/hms/execute-action", json=self._VALID_BODY)
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_not_connected(self, async_client: AsyncClient, printer_factory):
+        """400 when the printer record exists but the MQTT client is offline."""
+        printer = await printer_factory(name="Disconnected Printer")
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
+            )
+
+            assert response.status_code == 400
+            assert "not connected" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_success(self, async_client: AsyncClient, printer_factory):
+        """200 happy path — dispatcher returns True AND printer state moves
+        within the ack-wait window. The state delta is the firmware's only
+        proof that the command landed (publish success is necessary but not
+        sufficient; see #1830 §(3))."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        # Pre-action state — paused with a fault.
+        mock_client.state.state = "PAUSE"
+        mock_client.state.print_error = 0x05008051
+        mock_client.state.hms_errors = [object()]
+
+        def _act(*_a, **_kw):
+            # Simulate the printer accepting the command and clearing the fault
+            # by the time the ack-wait expires.
+            mock_client.state.state = "FAILED"
+            mock_client.state.print_error = 0
+            mock_client.state.hms_errors = []
+            return True
+
+        mock_client.execute_hms_action.side_effect = _act
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
+        ):
+            mock_pm.get_client.return_value = mock_client
+
+            body = {"print_error": "07008029", "action": "FILAMENT_EXTRUDED", "job_id": "task-7"}
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
+
+            assert response.status_code == 200
+            result = response.json()
+            assert result["success"] is True
+            assert "executed" in result["message"].lower()
+            # Body args reach the client method in (print_error, action, job_id) order.
+            mock_client.execute_hms_action.assert_called_once_with("07008029", "FILAMENT_EXTRUDED", "task-7")
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_dispatcher_failure(self, async_client: AsyncClient, printer_factory):
+        """400 when the dispatcher returns False (unknown action, mid-flight disconnect)."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.execute_hms_action.return_value = False
+
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
+            )
+
+            assert response.status_code == 400
+            assert "failed" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_no_printer_ack_returns_502(self, async_client: AsyncClient, printer_factory):
+        """502 when publish succeeded but printer state didn't move within the
+        ack-wait window. This is the silent-rejection failure mode #1830
+        identifies: the broker ACKs the publish at QoS 1 but the firmware
+        drops the command (err mismatch, wrong shape, state mismatch).
+        Surfacing this as 502 instead of 200 stops the UI from claiming
+        success while the modal sticks."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.state = "PAUSE"
+        mock_client.state.print_error = 0x05008051
+        mock_client.state.hms_errors = [object()]
+        mock_client.execute_hms_action.return_value = True  # publish "succeeded"
+        # Crucially: state does NOT change → ack-wait detects no movement.
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
+        ):
+            mock_pm.get_client.return_value = mock_client
+
+            response = await async_client.post(
+                f"/api/v1/printers/{printer.id}/hms/execute-action", json=self._VALID_BODY
+            )
+
+            assert response.status_code == 502
+            assert "acknowledge" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_accepts_16_char_full_code(self, async_client: AsyncClient, printer_factory):
+        """200 for a 16-char full_code (hms[]-array-sourced fault). The
+        schema's relaxed pattern allows both 8-char (print_error) and
+        16-char (hms[]) shapes."""
+        printer = await printer_factory(name="Test Printer")
+
+        mock_client = MagicMock()
+        mock_client.state.state = "RUNNING"
+        mock_client.state.print_error = 0
+        mock_client.state.hms_errors = [object()]
+
+        def _act(*_a, **_kw):
+            mock_client.state.hms_errors = []
+            return True
+
+        mock_client.execute_hms_action.side_effect = _act
+
+        with (
+            patch("backend.app.api.routes.printers.printer_manager") as mock_pm,
+            patch("backend.app.api.routes.printers.HMS_ACTION_ACK_WAIT_SECONDS", 0.01),
+        ):
+            mock_pm.get_client.return_value = mock_client
+
+            body = {"print_error": "0C00030000020010", "action": "IGNORE_RESUME"}
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
+
+            assert response.status_code == 200
+            mock_client.execute_hms_action.assert_called_once_with("0C00030000020010", "IGNORE_RESUME", None)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_execute_hms_action_rejects_malformed_print_error(self, async_client: AsyncClient, printer_factory):
+        """422 when print_error fails the relaxed pattern (8 OR 16 hex chars).
+        Lengths in between (9-15) and outside (7, 17+) are invalid; stray
+        input can't reach the dispatcher's match statement."""
+        printer = await printer_factory(name="Test Printer")
+
+        bad_bodies = [
+            {"print_error": "0300_8070", "action": "OK_BUTTON"},  # underscore
+            {"print_error": "0300807", "action": "OK_BUTTON"},  # 7 chars (too short)
+            {"print_error": "030080700", "action": "OK_BUTTON"},  # 9 chars (between)
+            {"print_error": "030080700300807", "action": "OK_BUTTON"},  # 15 chars (between)
+            {"print_error": "0300807003008070A", "action": "OK_BUTTON"},  # 17 chars (too long)
+            {"print_error": "0300GGGG", "action": "OK_BUTTON"},  # non-hex
+        ]
+        for body in bad_bodies:
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/hms/execute-action", json=body)
+            assert response.status_code == 422, body
+
+
 def _build_h2d_state(*, ams_id: int = 0, tray_id: int = 2, cali_idx: int = 5):
     """Build a MagicMock PrinterState for an H2D printer with a single BL spool tray.
 
@@ -2997,3 +3178,646 @@ class TestConfigureAmsSlotPersistsKProfile:
         assert response.status_code == 200
         # MQTT was indeed called
         mock_client.extrusion_cali_sel.assert_called_once()
+
+
+class TestPrinterAccessCodeVisibility:
+    """Regression coverage: GET /printers and GET /printers/{id} must NOT
+    return ``access_code`` to callers without PRINTERS_UPDATE authority.
+
+    Holding ``access_code`` lets the caller talk to the printer's MQTT
+    directly with serial+code, bypassing every PRINTERS_CONTROL /
+    PRINTERS_FILES / PRINTERS_AMS_RFID check Bambuddy enforces.
+
+    Trust matrix encoded here:
+      - Auth disabled                  → access_code visible (single-trust mode)
+      - JWT Admin                      → access_code visible
+      - JWT Operator (has *_UPDATE)    → access_code visible (VP-card UX)
+      - JWT Viewer                     → access_code STRIPPED
+      - API key with can_read_status   → access_code STRIPPED
+    """
+
+    @pytest.fixture
+    async def auth_setup(self, async_client: AsyncClient):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "pcadmin",
+                "admin_password": "AdminPass1!",
+            },
+        )
+
+        async def _login(username, password):
+            resp = await async_client.post(
+                "/api/v1/auth/login",
+                json={"username": username, "password": password},
+            )
+            return resp.json()["access_token"]
+
+        admin_token = await _login("pcadmin", "AdminPass1!")
+
+        groups = (
+            await async_client.get(
+                "/api/v1/groups/",
+                headers={"Authorization": f"Bearer {admin_token}"},
+            )
+        ).json()
+        operators_group = next(g for g in groups if g["name"] == "Operators")
+        viewers_group = next(g for g in groups if g["name"] == "Viewers")
+
+        for username, password, group in (
+            ("pcoperator", "Operpass1!", operators_group["id"]),
+            ("pcviewer", "Viewpass1!", viewers_group["id"]),
+        ):
+            await async_client.post(
+                "/api/v1/users/",
+                headers={"Authorization": f"Bearer {admin_token}"},
+                json={"username": username, "password": password, "group_ids": [group]},
+            )
+
+        operator_token = await _login("pcoperator", "Operpass1!")
+        viewer_token = await _login("pcviewer", "Viewpass1!")
+
+        return {
+            "admin_token": admin_token,
+            "operator_token": operator_token,
+            "viewer_token": viewer_token,
+        }
+
+    async def _seed_printer_with_known_code(self, async_client: AsyncClient, admin_token: str) -> int:
+        resp = await async_client.post(
+            "/api/v1/printers/",
+            headers={"Authorization": f"Bearer {admin_token}"},
+            json={
+                "name": "AC-Visibility",
+                "serial_number": "00M09AVISIBILITY",
+                "ip_address": "192.168.42.42",
+                "access_code": "SECRET-CODE",
+                "is_active": True,
+                "model": "X1C",
+            },
+        )
+        assert resp.status_code == 200, resp.text
+        return resp.json()["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auth_disabled_includes_access_code(self, async_client: AsyncClient, printer_factory):
+        """Single-trust mode: behaviour preserved, code is visible."""
+        printer = await printer_factory(name="AuthOff", access_code="LOCAL-CODE")
+
+        list_resp = await async_client.get("/api/v1/printers/")
+        detail_resp = await async_client.get(f"/api/v1/printers/{printer.id}")
+
+        assert list_resp.status_code == 200
+        assert detail_resp.status_code == 200
+        match = next(p for p in list_resp.json() if p["id"] == printer.id)
+        assert match["access_code"] == "LOCAL-CODE"
+        assert detail_resp.json()["access_code"] == "LOCAL-CODE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_admin_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
+        printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
+        headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+
+        list_resp = await async_client.get("/api/v1/printers/", headers=headers)
+        detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
+
+        assert list_resp.status_code == 200
+        assert detail_resp.status_code == 200
+        match = next(p for p in list_resp.json() if p["id"] == printer_id)
+        assert match["access_code"] == "SECRET-CODE"
+        assert detail_resp.json()["access_code"] == "SECRET-CODE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_operator_jwt_includes_access_code(self, async_client: AsyncClient, auth_setup):
+        """Operators hold PRINTERS_UPDATE (default role) — the VP-card UX
+        surfaces the target printer's access_code so they can configure
+        their slicer. The visibility predicate must keep working for them.
+        """
+        printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
+        headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
+
+        list_resp = await async_client.get("/api/v1/printers/", headers=headers)
+        detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
+
+        assert list_resp.status_code == 200
+        assert detail_resp.status_code == 200
+        match = next(p for p in list_resp.json() if p["id"] == printer_id)
+        assert match["access_code"] == "SECRET-CODE"
+        assert detail_resp.json()["access_code"] == "SECRET-CODE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_viewer_jwt_excludes_access_code(self, async_client: AsyncClient, auth_setup):
+        """The fix: Viewers hold PRINTERS_READ but not PRINTERS_UPDATE, and
+        must NOT be able to read the printer's secret.
+        """
+        printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
+        headers = {"Authorization": f"Bearer {auth_setup['viewer_token']}"}
+
+        list_resp = await async_client.get("/api/v1/printers/", headers=headers)
+        detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers=headers)
+
+        assert list_resp.status_code == 200
+        assert detail_resp.status_code == 200
+        match = next(p for p in list_resp.json() if p["id"] == printer_id)
+        # Field absent OR null — both are acceptable (no usable secret reaches the wire).
+        assert "access_code" not in match or match["access_code"] is None
+        body = detail_resp.json()
+        assert "access_code" not in body or body["access_code"] is None
+        # And the rest of the payload still arrives so the UI keeps working.
+        assert match["name"] == "AC-Visibility"
+        assert body["name"] == "AC-Visibility"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_api_key_excludes_access_code(self, async_client: AsyncClient, auth_setup, db_session):
+        """API keys with can_read_status hold PRINTERS_READ but the predicate
+        gates on PRINTERS_UPDATE (admin-only / API-key-unmapped). The key
+        must NOT be able to exfiltrate access_code.
+        """
+        from backend.app.core.auth import generate_api_key
+        from backend.app.models.api_key import APIKey
+
+        printer_id = await self._seed_printer_with_known_code(async_client, auth_setup["admin_token"])
+
+        full_key, key_hash, key_prefix = generate_api_key()
+        api_key = APIKey(
+            name="visibility-key",
+            key_hash=key_hash,
+            key_prefix=key_prefix,
+            can_read_status=True,
+            enabled=True,
+        )
+        db_session.add(api_key)
+        await db_session.commit()
+
+        list_resp = await async_client.get("/api/v1/printers/", headers={"X-API-Key": full_key})
+        detail_resp = await async_client.get(f"/api/v1/printers/{printer_id}", headers={"X-API-Key": full_key})
+
+        assert list_resp.status_code == 200
+        assert detail_resp.status_code == 200
+        match = next(p for p in list_resp.json() if p["id"] == printer_id)
+        assert "access_code" not in match or match["access_code"] is None
+        body = detail_resp.json()
+        assert "access_code" not in body or body["access_code"] is None
+
+
+class TestSetNozzleTemperatureAPI:
+    """Integration tests for POST /printers/{id}/temperature/nozzle (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/temperature/nozzle?target=220")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_nozzle_index(self, async_client: AsyncClient, printer_factory):
+        """Omitting nozzle defaults to 0 (right/default)."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
+        assert response.status_code == 200
+        mock_client.set_nozzle_temperature.assert_called_once_with(220, 0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dual_nozzle_left(self, async_client: AsyncClient, printer_factory):
+        """nozzle=1 reaches the client method as the second positional arg."""
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=260&nozzle=1")
+        assert response.status_code == 200
+        mock_client.set_nozzle_temperature.assert_called_once_with(260, 1)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
+        """target=0 turns the heater off; must NOT be rejected by Query bounds."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=0")
+        assert response.status_code == 200
+        mock_client.set_nozzle_temperature.assert_called_once_with(0, 0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=400")
+        assert response.status_code == 422  # FastAPI bounds violation
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_nozzle_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220&nozzle=2")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_nozzle_temperature.return_value = False
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/nozzle?target=220")
+        assert response.status_code == 500
+
+
+class TestSetBedTemperatureAPI:
+    """Integration tests for POST /printers/{id}/temperature/bed (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/temperature/bed?target=60")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_success(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_bed_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
+        assert response.status_code == 200
+        mock_client.set_bed_temperature.assert_called_once_with(60)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
+        """target=0 turns the bed heater off."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_bed_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=0")
+        assert response.status_code == 200
+        mock_client.set_bed_temperature.assert_called_once_with(0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=200")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_bed_temperature.return_value = False
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/bed?target=60")
+        assert response.status_code == 500
+
+
+class TestSetChamberTemperatureAPI:
+    """Integration tests for POST /printers/{id}/temperature/chamber.
+
+    Gated on supports_chamber_heater(model). Sensor-only models that report
+    chamber temp but have no heater (X1C, X1E, P2S) get a 400 at the route
+    level rather than a silent no-op at the firmware level.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/temperature/chamber?target=45")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("sensor_only_model", ["X1C", "X1E", "P2S"])
+    async def test_sensor_only_model_rejected(self, async_client: AsyncClient, printer_factory, sensor_only_model):
+        """Models with sensor but no heater must 400 before any client call."""
+        printer = await printer_factory(name="P", model=sensor_only_model)
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
+        assert response.status_code == 400
+        # Client must NOT be called for sensor-only models.
+        mock_client.set_chamber_temperature.assert_not_called()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("heater_model", ["H2C", "H2D", "H2DPRO", "H2S", "X2D"])
+    async def test_success_per_heater_model(self, async_client: AsyncClient, printer_factory, heater_model):
+        """All five heater-equipped models accept the command."""
+        printer = await printer_factory(name="P", model=heater_model)
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
+        assert response.status_code == 200
+        mock_client.set_chamber_temperature.assert_called_once_with(45)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_zero_allowed(self, async_client: AsyncClient, printer_factory):
+        """target=0 turns the chamber heater off."""
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=0")
+        assert response.status_code == 200
+        mock_client.set_chamber_temperature.assert_called_once_with(0)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_target_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.set_chamber_temperature.return_value = False
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=45")
+        assert response.status_code == 500
+
+
+class TestSetFanSpeedAPI:
+    """Integration tests for POST /printers/{id}/fan-speed (#1661).
+
+    The fan-id mapping (part->1, aux->2, chamber->3) is the critical
+    correctness gate — wrong mapping would target the wrong physical fan.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_fan_name_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=foo&speed=50")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=50")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "fan_name,expected_fan_id",
+        [("part", 1), ("aux", 2), ("chamber", 3)],
+    )
+    async def test_fan_id_mapping(self, async_client: AsyncClient, printer_factory, fan_name, expected_fan_id):
+        """Verify each fan name maps to the correct hardware fan-id."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan={fan_name}&speed=100")
+        assert response.status_code == 200
+        called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
+        assert called_fan_id == expected_fan_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(
+        "speed_pct,expected_pwm",
+        [(0, 0), (50, 128), (100, 255)],
+    )
+    async def test_pwm_conversion(self, async_client: AsyncClient, printer_factory, speed_pct, expected_pwm):
+        """0-100% must convert to 0-255 PWM (round-to-nearest)."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.set_fan_speed.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed={speed_pct}")
+        assert response.status_code == 200
+        _called_fan_id, called_pwm = mock_client.set_fan_speed.call_args.args
+        assert called_pwm == expected_pwm
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_speed_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/fan-speed?fan=part&speed=150")
+        assert response.status_code == 422
+
+
+class TestSelectExtruderAPI:
+    """Integration tests for POST /printers/{id}/select-extruder (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_found(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/printers/99999/select-extruder?extruder=0")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=0")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("extruder", [0, 1])
+    async def test_select_each_extruder(self, async_client: AsyncClient, printer_factory, extruder):
+        printer = await printer_factory(name="P", model="H2D")
+        mock_client = MagicMock()
+        mock_client.select_extruder.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder={extruder}")
+        assert response.status_code == 200
+        mock_client.select_extruder.assert_called_once_with(extruder)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extruder_index_out_of_range_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="H2D")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/select-extruder?extruder=2")
+        assert response.status_code == 422
+
+
+class TestXYJogAPI:
+    """Integration tests for POST /printers/{id}/xy-jog (#1661)."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zero_movement_rejected(self, async_client: AsyncClient, printer_factory):
+        """x=0 AND y=0 (or omitted) must be rejected — no-op jog is a UI bug."""
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("x,y", [(201, 0), (0, 201), (-300, 0), (0, -250)])
+    async def test_oversize_movement_rejected(self, async_client: AsyncClient, printer_factory, x, y):
+        """Per-axis bound is 200mm; over-bound must be rejected."""
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x={x}&y={y}")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_success_x_only_emits_relative_gcode(self, async_client: AsyncClient, printer_factory):
+        """X-only jog should emit G91/G90 wrapping and only include the X axis."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=10&y=0")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert sent.startswith("G91\n")
+        assert sent.endswith("\nG90")
+        assert "X10.00" in sent
+        assert "Y" not in sent  # y=0 must NOT be included
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_success_both_axes(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/xy-jog?x=-5&y=7")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert "X-5.00" in sent and "Y7.00" in sent
+
+
+class TestExtruderJogAPI:
+    """Integration tests for POST /printers/{id}/extruder-jog (#1661).
+
+    Note: Bambu firmware enforces the cold-extrude guard
+    (min-temp refusal) so the route deliberately does not gate on temp.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zero_distance_rejected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=0")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("distance", [101, -200])
+    async def test_oversize_distance_rejected(self, async_client: AsyncClient, printer_factory, distance):
+        """Per-axis bound is 100mm; over-bound must be rejected."""
+        printer = await printer_factory(name="P", model="X1C")
+        response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance={distance}")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_not_connected(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = None
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=5")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_extrude_uses_relative_e_mode(self, async_client: AsyncClient, printer_factory):
+        """Extruder jog must wrap with M83 (relative E) and restore M82 (absolute E)."""
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=5")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert sent.startswith("M83\n")
+        assert sent.endswith("\nM82")
+        assert "E5.00" in sent
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_retract_uses_negative_distance(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory(name="P", model="X1C")
+        mock_client = MagicMock()
+        mock_client.send_gcode.return_value = True
+        with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+            mock_pm.get_client.return_value = mock_client
+            response = await async_client.post(f"/api/v1/printers/{printer.id}/extruder-jog?distance=-3.5")
+        assert response.status_code == 200
+        sent = mock_client.send_gcode.call_args.args[0]
+        assert "E-3.50" in sent

+ 229 - 0
backend/tests/integration/test_session_policy.py

@@ -0,0 +1,229 @@
+"""Integration tests for the admin-set session-lifetime ceiling (#1706).
+
+Covers the four token-issuance sites that read ``session_max_hours``:
+plain login, 2FA backup-code login, 2FA TOTP/email login, OIDC login.
+Only the first is exercised end-to-end via ``async_client``; the helper
+``resolve_session_max_minutes`` itself is unit-tested below so the MFA
+and OIDC paths inherit the same clamping behaviour by construction.
+"""
+
+import time
+
+import jwt
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import (
+    ACCESS_TOKEN_EXPIRE_MINUTES,
+    ALGORITHM,
+    SECRET_KEY,
+    SESSION_MAX_HOURS_HARD_CEILING,
+    resolve_session_max_minutes,
+)
+from backend.app.models.settings import Settings
+
+
+async def _set_session_max_hours(db: AsyncSession, value: str | None) -> None:
+    """Upsert the session_max_hours setting row (value=None deletes it)."""
+    result = await db.execute(select(Settings).where(Settings.key == "session_max_hours"))
+    existing = result.scalar_one_or_none()
+    if value is None:
+        if existing is not None:
+            await db.delete(existing)
+            await db.commit()
+        return
+    if existing is None:
+        db.add(Settings(key="session_max_hours", value=value))
+    else:
+        existing.value = value
+    await db.commit()
+
+
+class TestResolveSessionMaxMinutes:
+    """Unit-style tests for the clamping resolver."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_missing_row_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, None)
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+        assert ACCESS_TOKEN_EXPIRE_MINUTES == 60 * 24
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_string_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unparseable_value_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "not-a-number")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_zero_or_negative_returns_24h_default(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "0")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+        await _set_session_max_hours(db_session, "-5")
+        assert await resolve_session_max_minutes(db_session) == ACCESS_TOKEN_EXPIRE_MINUTES
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_hour_minimum(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "1")
+        assert await resolve_session_max_minutes(db_session) == 60
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_seven_days_passes_through(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, "168")
+        assert await resolve_session_max_minutes(db_session) == 168 * 60
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_thirty_days_passes_through(self, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, str(SESSION_MAX_HOURS_HARD_CEILING))
+        assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_above_ceiling_is_clamped_to_30_days(self, db_session: AsyncSession):
+        """Defense-in-depth: a tampered settings row above 720h must be clamped."""
+        await _set_session_max_hours(db_session, "99999")
+        assert await resolve_session_max_minutes(db_session) == SESSION_MAX_HOURS_HARD_CEILING * 60
+
+
+class TestLoginRespectsSessionPolicy:
+    """The /auth/login route must honour the resolved ceiling."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_uses_default_24h_when_unset(self, async_client: AsyncClient, db_session: AsyncSession):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "sessiontest1",
+                "admin_password": "SessionPass1!",
+            },
+        )
+        await _set_session_max_hours(db_session, None)
+
+        before = int(time.time())
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "sessiontest1", "password": "SessionPass1!"},
+        )
+        after = int(time.time())
+
+        assert response.status_code == 200
+        token = response.json()["access_token"]
+        decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        # exp should be ~24h ahead. Allow generous bounds for clock drift.
+        expected_min = before + 24 * 3600 - 60
+        expected_max = after + 24 * 3600 + 60
+        assert expected_min <= decoded["exp"] <= expected_max
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_uses_configured_7d_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "sessiontest2",
+                "admin_password": "SessionPass2!",
+            },
+        )
+        await _set_session_max_hours(db_session, "168")  # 7 days
+
+        before = int(time.time())
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "sessiontest2", "password": "SessionPass2!"},
+        )
+        after = int(time.time())
+
+        assert response.status_code == 200
+        token = response.json()["access_token"]
+        decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        expected_min = before + 168 * 3600 - 60
+        expected_max = after + 168 * 3600 + 60
+        assert expected_min <= decoded["exp"] <= expected_max
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_login_clamps_above_ceiling(self, async_client: AsyncClient, db_session: AsyncSession):
+        """A settings row above the 720h ceiling must be clamped at login time."""
+        await async_client.post(
+            "/api/v1/auth/setup",
+            json={
+                "auth_enabled": True,
+                "admin_username": "sessiontest3",
+                "admin_password": "SessionPass3!",
+            },
+        )
+        await _set_session_max_hours(db_session, "5000")  # would be ~208 days
+
+        before = int(time.time())
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "sessiontest3", "password": "SessionPass3!"},
+        )
+        after = int(time.time())
+
+        assert response.status_code == 200
+        token = response.json()["access_token"]
+        decoded = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+        # Clamped to 30 days, not 5000 hours.
+        expected_min = before + SESSION_MAX_HOURS_HARD_CEILING * 3600 - 60
+        expected_max = after + SESSION_MAX_HOURS_HARD_CEILING * 3600 + 60
+        assert expected_min <= decoded["exp"] <= expected_max
+
+
+class TestSettingsAPIExposesSessionMaxHours:
+    """The /settings API must round-trip session_max_hours as an int."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_default_is_24(self, async_client: AsyncClient, db_session: AsyncSession):
+        await _set_session_max_hours(db_session, None)
+        response = await async_client.get("/api/v1/settings/")
+        assert response.status_code == 200
+        assert response.json()["session_max_hours"] == 24
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_accepts_valid_value(self, async_client: AsyncClient, db_session: AsyncSession):
+        response = await async_client.patch(
+            "/api/v1/settings/",
+            json={"session_max_hours": 168},
+        )
+        assert response.status_code == 200
+        assert response.json()["session_max_hours"] == 168
+        # Persisted as the int's string form so the resolver round-trips.
+        result = await db_session.execute(select(Settings).where(Settings.key == "session_max_hours"))
+        row = result.scalar_one()
+        assert row.value == "168"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_zero(self, async_client: AsyncClient):
+        response = await async_client.patch(
+            "/api/v1/settings/",
+            json={"session_max_hours": 0},
+        )
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_rejects_above_ceiling(self, async_client: AsyncClient):
+        response = await async_client.patch(
+            "/api/v1/settings/",
+            json={"session_max_hours": SESSION_MAX_HOURS_HARD_CEILING + 1},
+        )
+        assert response.status_code == 422

+ 5 - 0
backend/tests/integration/test_settings_ui_preferences.py

@@ -83,11 +83,16 @@ class TestUiPreferencesEndpoint:
             "time_format",
             "date_format",
             "drying_presets",
+            "ams_humidity_thresholds",
             "ams_humidity_good",
             "ams_humidity_fair",
             "ams_temp_good",
             "ams_temp_fair",
             "bed_cooled_threshold",
+            "nozzle_temp_presets",
+            "bed_temp_presets",
+            "chamber_temp_presets",
+            "fan_speed_presets",
         }
         assert set(data.keys()) == expected
 

+ 48 - 0
backend/tests/integration/test_sponsor_prompt_api.py

@@ -0,0 +1,48 @@
+"""Integration tests for /sponsor-prompt routes."""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSponsorPromptAPI:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_returns_show_false_for_empty_install(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/sponsor-prompt/check")
+        assert response.status_code == 200
+        body = response.json()
+        assert body["show"] is False
+        assert body.get("milestone") is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dismiss_requires_milestone(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/sponsor-prompt/dismiss", json={})
+        # Pydantic missing-field → 422.
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_dismiss_returns_204(self, async_client: AsyncClient):
+        response = await async_client.post(
+            "/api/v1/sponsor-prompt/dismiss",
+            json={"milestone": "version-update"},
+        )
+        assert response.status_code == 204
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_check_then_dismiss_then_recheck_is_silent(self, async_client: AsyncClient):
+        """End-to-end: even if no trigger is currently eligible, dismissing
+        anchors the cooldown, so a subsequent check stays {show: false}."""
+        first = await async_client.get("/api/v1/sponsor-prompt/check")
+        assert first.json()["show"] is False
+        dismiss = await async_client.post(
+            "/api/v1/sponsor-prompt/dismiss",
+            json={"milestone": "version-update"},
+        )
+        assert dismiss.status_code == 204
+        second = await async_client.get("/api/v1/sponsor-prompt/check")
+        assert second.json()["show"] is False

+ 188 - 0
backend/tests/integration/test_spool_by_tag_lookup.py

@@ -0,0 +1,188 @@
+"""By-tag spool lookup endpoint (#1663).
+
+``GET /api/v1/inventory/spools/by-tag`` lets NFC inventory integrations
+dedupe a scan by ``tray_uuid``/``tag_uid`` without listing the whole
+inventory, and is readable with either a read-status or a manage-inventory
+API-key scope.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.models.settings import Settings
+from backend.app.models.spool import Spool
+
+TRAY_UUID = "AABBCCDDEEFF0011AABBCCDDEEFF0011"
+TAG_UID = "04A1B2C3"
+
+
+@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 TestSpoolByTagLookup:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_by_tray_uuid(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_by_tag_uid(self, async_client: AsyncClient, spool_factory):
+        spool = await spool_factory(tag_uid=TAG_UID)
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tag_uid={TAG_UID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_normalizes_input(self, async_client: AsyncClient, spool_factory):
+        """Lowercase / separator-laden input still matches the stored hex."""
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        messy = "aa:bb:cc:dd:ee:ff:00:11:aa:bb:cc:dd:ee:ff:00:11"
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={messy}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_preferred_over_tag_uid(self, async_client: AsyncClient, spool_factory):
+        """When both identifiers are given, tray_uuid wins (it's the AMS key)."""
+        by_uuid = await spool_factory(tray_uuid=TRAY_UUID)
+        await spool_factory(tag_uid=TAG_UID)
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}&tag_uid={TAG_UID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == by_uuid.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_tray_uuid_miss_falls_through_to_tag_uid(self, async_client: AsyncClient, spool_factory):
+        """tray_uuid is tried first, but a miss must fall through to tag_uid, not 404."""
+        by_tag = await spool_factory(tag_uid=TAG_UID)
+        unknown_tray = "FF" * 16
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={unknown_tray}&tag_uid={TAG_UID}")
+        assert resp.status_code == 200
+        assert resp.json()["id"] == by_tag.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_identifier_is_400(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_non_hex_identifier_is_400(self, async_client: AsyncClient):
+        """A value with no hex characters normalizes to empty → treated as absent."""
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag?tray_uuid=zzz")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_no_match_is_404(self, async_client: AsyncClient, spool_factory):
+        await spool_factory(tray_uuid=TRAY_UUID)
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag?tray_uuid=" + "FF" * 16)
+        assert resp.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archived_excluded_by_default(self, async_client: AsyncClient, spool_factory):
+        from datetime import datetime, timezone
+
+        await spool_factory(tray_uuid=TRAY_UUID, archived_at=datetime.now(timezone.utc))
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}")
+        assert resp.status_code == 404
+
+        resp = await async_client.get(f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}&include_archived=true")
+        assert resp.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_does_not_collide_with_spool_id_route(self, async_client: AsyncClient, spool_factory):
+        """'by-tag' must route to the lookup handler, not /spools/{spool_id}."""
+        await spool_factory(tray_uuid=TRAY_UUID)
+        resp = await async_client.get("/api/v1/inventory/spools/by-tag?tray_uuid=" + TRAY_UUID)
+        assert resp.status_code == 200
+
+
+async def _make_api_key(db_session: AsyncSession, **scopes) -> str:
+    full_key, key_hash, key_prefix = generate_api_key()
+    flags = {
+        "can_queue": False,
+        "can_control_printer": False,
+        "can_read_status": False,
+        "can_manage_inventory": False,
+    }
+    flags.update(scopes)
+    db_session.add(APIKey(name="test-key", key_hash=key_hash, key_prefix=key_prefix, enabled=True, **flags))
+    db_session.add(Settings(key="auth_enabled", value="true"))
+    await db_session.commit()
+    return full_key
+
+
+class TestSpoolByTagApiKeyScope:
+    """The endpoint is the core ask of #1663: a Manage-Inventory key (which can
+    already create/update spools) must be able to read them back."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_manage_inventory_key_can_read(self, async_client: AsyncClient, db_session, spool_factory):
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        key = await _make_api_key(db_session, can_manage_inventory=True)
+        resp = await async_client.get(
+            f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_read_status_key_can_read(self, async_client: AsyncClient, db_session, spool_factory):
+        spool = await spool_factory(tray_uuid=TRAY_UUID)
+        key = await _make_api_key(db_session, can_read_status=True)
+        resp = await async_client.get(
+            f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 200
+        assert resp.json()["id"] == spool.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_key_without_inventory_scope_is_denied(self, async_client: AsyncClient, db_session, spool_factory):
+        await spool_factory(tray_uuid=TRAY_UUID)
+        key = await _make_api_key(db_session, can_control_printer=True)
+        resp = await async_client.get(
+            f"/api/v1/inventory/spools/by-tag?tray_uuid={TRAY_UUID}",
+            headers={"X-API-Key": key},
+        )
+        assert resp.status_code == 403

+ 4 - 0
backend/tests/integration/test_spoolman_inventory_api.py

@@ -74,6 +74,10 @@ def mock_spoolman_client():
     # branch override this on the fly.
     mock_client.is_filament_shared = AsyncMock(return_value=False)
     mock_client.ensure_extra_field = AsyncMock(return_value=True)
+    # list_spools calls maybe_sync_spoolman_locations which invokes
+    # get_distinct_locations on the route-resolved client. Empty list keeps the
+    # mock honest without staging phantom catalog rows.
+    mock_client.get_distinct_locations = AsyncMock(return_value=[])
 
     with (
         patch(

+ 200 - 0
backend/tests/integration/test_spoolman_inventory_bulk.py

@@ -0,0 +1,200 @@
+"""Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
+
+Endpoints under test:
+- POST /api/v1/spoolman/inventory/spools/bulk-update
+- POST /api/v1/spoolman/inventory/spools/bulk-delete
+- POST /api/v1/spoolman/inventory/spools/bulk-archive
+- POST /api/v1/spoolman/inventory/spools/bulk-restore
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from httpx import AsyncClient
+
+SAMPLE_SPOOLMAN_SPOOL = {
+    "id": 42,
+    "filament": {
+        "id": 7,
+        "name": "PLA Basic",
+        "material": "PLA",
+        "color_hex": "FF0000",
+        "weight": 1000,
+        "vendor": {"id": 3, "name": "Bambu Lab"},
+    },
+    "remaining_weight": 750.0,
+    "used_weight": 250.0,
+    "location": "Printer1 - AMS A1",
+    "comment": "test note",
+    "first_used": "2024-01-01T00:00:00+00:00",
+    "last_used": "2024-02-01T00:00:00+00:00",
+    "registered": "2024-01-01T00:00:00+00:00",
+    "archived": False,
+    "price": None,
+    "extra": {},
+}
+
+
+@pytest.fixture
+async def spoolman_settings(db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="spoolman_enabled", value="true"))
+    db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
+    await db_session.commit()
+
+
+@pytest.fixture
+def mock_spoolman_client():
+    mock = MagicMock()
+    mock.base_url = "http://localhost:7912"
+    mock.health_check = AsyncMock(return_value=True)
+    mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.delete_spool = AsyncMock(return_value=True)
+    mock.set_spool_archived = AsyncMock(
+        side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
+    )
+    mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
+    mock.is_filament_shared = AsyncMock(return_value=False)
+    mock.patch_filament = AsyncMock(return_value={"id": 7})
+    mock.find_or_create_filament = AsyncMock(return_value=7)
+    mock.find_or_create_vendor = AsyncMock(return_value=3)
+    mock.ensure_extra_field = AsyncMock(return_value=True)
+    mock.get_distinct_locations = AsyncMock(return_value=[])
+
+    class _Lock:
+        async def __aenter__(self):
+            return self
+
+        async def __aexit__(self, *args):
+            return False
+
+    mock.extra_lock = lambda spool_id: _Lock()
+
+    with (
+        patch(
+            "backend.app.api.routes.spoolman_inventory.get_spoolman_client",
+            AsyncMock(return_value=mock),
+        ),
+        patch(
+            "backend.app.api.routes.spoolman_inventory.init_spoolman_client",
+            AsyncMock(return_value=mock),
+        ),
+    ):
+        yield mock
+
+
+class TestSpoolmanBulkUpdate:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_calls_per_spool_update_for_each_id(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 3
+        assert body["errors"] == []
+        # update_spool route loops through each, which calls update_spool_full once per ID
+        assert mock_spoolman_client.update_spool_full.await_count == 3
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_collects_per_spool_errors_without_aborting_batch(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        # First two succeed, third raises
+        mock_spoolman_client.update_spool_full.side_effect = [
+            SAMPLE_SPOOLMAN_SPOOL,
+            SAMPLE_SPOOLMAN_SPOOL,
+            HTTPException(status_code=404, detail="Spool 999 not found"),
+        ]
+
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["updated"] == 2
+        assert len(body["errors"]) == 1
+        assert body["errors"][0]["id"] == 999
+        assert body["errors"][0]["status"] == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [42], "update": {}},
+        )
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-update",
+            json={"ids": [], "update": {"note": "X"}},
+        )
+        assert resp.status_code == 422
+
+
+class TestSpoolmanBulkDelete:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-delete",
+            json={"ids": [42, 43, 44]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["deleted"] == 3
+        assert body["errors"] == []
+        assert mock_spoolman_client.delete_spool.await_count == 3
+
+
+class TestSpoolmanBulkArchiveRestore:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_archive_calls_per_spool(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-archive",
+            json={"ids": [42, 43]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["archived"] == 2
+        # set_spool_archived(spool_id, archived=True) called for each id
+        assert mock_spoolman_client.set_spool_archived.await_count == 2
+        for call in mock_spoolman_client.set_spool_archived.call_args_list:
+            assert call.kwargs.get("archived") is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bulk_restore_calls_per_spool(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        resp = await async_client.post(
+            "/api/v1/spoolman/inventory/spools/bulk-restore",
+            json={"ids": [42, 43]},
+        )
+
+        assert resp.status_code == 200
+        body = resp.json()
+        assert body["restored"] == 2
+        assert mock_spoolman_client.set_spool_archived.await_count == 2
+        for call in mock_spoolman_client.set_spool_archived.call_args_list:
+            assert call.kwargs.get("archived") is False

+ 1 - 0
backend/tests/integration/test_spoolman_k_profiles.py

@@ -67,6 +67,7 @@ def mock_spoolman_client():
     client.health_check = AsyncMock(return_value=True)
     client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
     client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOL])
+    client.get_distinct_locations = AsyncMock(return_value=[])
 
     with patch(
         "backend.app.api.routes.spoolman_inventory._get_client",

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů