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

Merge branch 'dev' into feature/slicer-multi-button

MartinNYHC 1 месяц назад
Родитель
Сommit
932a229dea
64 измененных файлов с 6707 добавлено и 312 удалено
  1. 1 0
      CHANGELOG.md
  2. 397 62
      backend/app/api/routes/print_queue.py
  3. 17 13
      backend/app/api/routes/printers.py
  4. 23 0
      backend/app/core/database.py
  5. 40 1
      backend/app/main.py
  6. 2 1
      backend/app/models/__init__.py
  7. 58 2
      backend/app/models/print_batch.py
  8. 7 0
      backend/app/models/print_log.py
  9. 87 0
      backend/app/schemas/print_queue.py
  10. 65 0
      backend/app/services/bambu_mqtt.py
  11. 541 0
      backend/app/services/print_batch.py
  12. 2 0
      backend/app/services/print_log.py
  13. 81 9
      backend/app/services/print_scheduler.py
  14. 88 13
      backend/app/services/printer_manager.py
  15. 68 0
      backend/app/services/virtual_printer/diagnostic.py
  16. 51 0
      backend/tests/integration/test_overlay_status_api.py
  17. 117 0
      backend/tests/integration/test_ownership_permissions.py
  18. 833 0
      backend/tests/integration/test_print_batch_orders.py
  19. 37 0
      backend/tests/integration/test_security_headers.py
  20. 161 0
      backend/tests/unit/services/test_bambu_mqtt.py
  21. 111 2
      backend/tests/unit/services/test_printer_manager.py
  22. 112 2
      backend/tests/unit/services/test_vp_diagnostic.py
  23. 185 0
      backend/tests/unit/test_scheduler_watchdog.py
  24. 8 0
      deploy/bambuddy.service
  25. 229 0
      frontend/src/__tests__/components/BatchOrdersView.test.tsx
  26. 162 1
      frontend/src/__tests__/components/PrintModal.test.tsx
  27. 156 0
      frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx
  28. 62 1
      frontend/src/__tests__/contexts/ToastContext.test.tsx
  29. 94 24
      frontend/src/__tests__/hooks/useWebSocket.test.ts
  30. 262 0
      frontend/src/__tests__/pages/PrintersPageCardScale.test.tsx
  31. 181 0
      frontend/src/__tests__/pages/PrintersPageExternalSpoolToggle.test.tsx
  32. 145 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  33. 101 0
      frontend/src/__tests__/utils/printerCardPrefs.test.ts
  34. 77 0
      frontend/src/api/client.ts
  35. 345 0
      frontend/src/components/BatchOrdersView.tsx
  36. 41 4
      frontend/src/components/PrintModal/PlateSelector.tsx
  37. 59 13
      frontend/src/components/PrintModal/index.tsx
  38. 8 0
      frontend/src/components/PrintModal/types.ts
  39. 301 0
      frontend/src/components/StreamOverlayBuilder.tsx
  40. 12 2
      frontend/src/contexts/ToastContext.tsx
  41. 36 27
      frontend/src/hooks/useWebSocket.ts
  42. 69 0
      frontend/src/i18n/locales/de.ts
  43. 69 0
      frontend/src/i18n/locales/en.ts
  44. 69 0
      frontend/src/i18n/locales/es.ts
  45. 69 0
      frontend/src/i18n/locales/fr.ts
  46. 69 0
      frontend/src/i18n/locales/it.ts
  47. 69 0
      frontend/src/i18n/locales/ja.ts
  48. 69 0
      frontend/src/i18n/locales/ko.ts
  49. 69 0
      frontend/src/i18n/locales/pt-BR.ts
  50. 69 0
      frontend/src/i18n/locales/ru.ts
  51. 69 0
      frontend/src/i18n/locales/tr.ts
  52. 69 0
      frontend/src/i18n/locales/uk.ts
  53. 69 0
      frontend/src/i18n/locales/zh-CN.ts
  54. 69 0
      frontend/src/i18n/locales/zh-TW.ts
  55. 215 125
      frontend/src/pages/PrintersPage.tsx
  56. 20 5
      frontend/src/pages/QueuePage.tsx
  57. 17 1
      frontend/src/pages/SettingsPage.tsx
  58. 129 1
      frontend/src/pages/StreamOverlayPage.tsx
  59. 56 0
      frontend/src/utils/printerCardPrefs.ts
  60. 7 0
      spoolbuddy/install/install.sh
  61. 1 0
      static/assets/index-B3jj6-fz.css
  62. 0 0
      static/assets/index-B67xFyee.js
  63. 0 1
      static/assets/index-GBTQ2eaA.css
  64. 2 2
      static/index.html

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


+ 397 - 62
backend/app/api/routes/print_queue.py

@@ -19,15 +19,19 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.archive import PrintArchive
 from backend.app.models.library import LibraryFile
-from backend.app.models.print_batch import PrintBatch
+from backend.app.models.print_batch import PrintBatch, PrintBatchPlate
 from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
 from backend.app.models.printer import Printer
 from backend.app.models.project import Project
 from backend.app.models.user import User
 from backend.app.schemas.print_queue import (
     PrintBatchCreate,
+    PrintBatchDispatchRequest,
+    PrintBatchPlateProgress,
+    PrintBatchPlateTarget,
     PrintBatchResponse,
     PrintBatchUngroupResponse,
+    PrintBatchUpdate,
     PrintQueueBulkUpdate,
     PrintQueueBulkUpdateResponse,
     PrintQueueItemCreate,
@@ -40,6 +44,12 @@ from backend.app.schemas.print_queue import (
 from backend.app.services.filament_deficit import compute_deficit_for_queue_item
 from backend.app.services.filament_requirements import overrides_for_plate
 from backend.app.services.notification_service import notification_service
+from backend.app.services.print_batch import (
+    BatchDispatchError,
+    dispatch_remaining,
+    load_progress,
+    refresh_batch_status,
+)
 from backend.app.utils.printer_models import (
     is_gcode_compatible,
 )
@@ -141,6 +151,117 @@ def _extract_filament_types_from_3mf(file_path: Path, plate_id: int | None = Non
 _extract_print_time_from_3mf = extract_print_time_from_3mf
 
 
+def _assert_can_queue_archive(archive: PrintArchive, current_user: User | None) -> None:
+    """Gate turning *archive* into a print. Raises rather than returning a verdict.
+
+    Shared by every route that creates queue items from an archive, so a new
+    one can't quietly become a weaker door to the same action than
+    ``POST /queue/`` is.
+
+    Two separate checks:
+
+    * IDOR fix (maziggy/bambuddy-security #2): without this, a caller with
+      QUEUE_CREATE could queue any user's archive even without ARCHIVES_READ on
+      it — Landon's PoC enumerated this on admin's archives as operator1. Gate
+      on ARCHIVES_READ_ALL OR ownership. 404 (not 403) so we don't leak "this
+      id exists but you can't queue it" for enumeration.
+    * Reprint perm gate (#1625): the legacy ``/archives/{id}/reprint`` endpoint
+      required ARCHIVES_REPRINT_OWN/ALL, and every route that replaces it must
+      keep that gate or an operator with QUEUE_CREATE could reprint via a
+      direct API call even when explicitly denied reprint perm. Mirrors the
+      frontend ``canModify('archives', 'reprint', ...)`` helper: REPRINT_ALL
+      allows any archive, REPRINT_OWN allows own only, ownerless archives
+      require REPRINT_ALL (fail-closed).
+    """
+    if current_user is None:
+        return
+    if not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value) and archive.created_by_id != current_user.id:
+        raise HTTPException(404, "Archive not found")
+    owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
+    has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
+        owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
+    )
+    if not has_reprint:
+        raise HTTPException(
+            status_code=403,
+            detail="Permission archives:reprint_own or archives:reprint_all required",
+        )
+
+
+def _assert_can_queue_library_file(library_file: LibraryFile, current_user: User | None) -> None:
+    """Gate turning *library_file* into a print — LIBRARY_READ_ALL or ownership."""
+    if current_user is None:
+        return
+    if (
+        not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
+        and library_file.created_by_id != current_user.id
+    ):
+        raise HTTPException(404, "Library file not found")
+
+
+async def _assert_can_dispatch_batch_sources(db: AsyncSession, batch_id: int, current_user: User | None) -> None:
+    """Apply the ``POST /queue/`` source-file gates to everything a dispatch would print.
+
+    Dispatching clones existing queue items, so without this it would be a
+    weaker door to the same outcome: a caller holding QUEUE_CREATE and
+    QUEUE_UPDATE_ALL but explicitly denied ``archives:reprint_*`` could start
+    prints through an order that ``POST /queue/`` would have refused them.
+
+    Every distinct source among the batch's items is checked, including the
+    library files behind cross-model variants — those get cloned too, and any
+    one of them may be the file that actually runs.
+    """
+    archive_ids = set(
+        (
+            await db.execute(
+                select(PrintQueueItem.archive_id)
+                .where(PrintQueueItem.batch_id == batch_id)
+                .where(PrintQueueItem.archive_id.is_not(None))
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+    library_file_ids = set(
+        (
+            await db.execute(
+                select(PrintQueueItem.library_file_id)
+                .where(PrintQueueItem.batch_id == batch_id)
+                .where(PrintQueueItem.library_file_id.is_not(None))
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+    library_file_ids |= set(
+        (
+            await db.execute(
+                select(PrintQueueVariant.library_file_id)
+                .join(PrintQueueItem, PrintQueueVariant.queue_item_id == PrintQueueItem.id)
+                .where(PrintQueueItem.batch_id == batch_id)
+                .distinct()
+            )
+        )
+        .scalars()
+        .all()
+    )
+
+    for archive_id in archive_ids:
+        archive = (await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))).scalar_one_or_none()
+        # A deleted source can't be printed; dispatch will fail on it anyway.
+        if archive is not None:
+            _assert_can_queue_archive(archive, current_user)
+
+    for library_file_id in library_file_ids:
+        library_file = (
+            await db.execute(LibraryFile.active().where(LibraryFile.id == library_file_id))
+        ).scalar_one_or_none()
+        if library_file is not None:
+            _assert_can_queue_library_file(library_file, current_user)
+
+
 async def _resolve_source_path(db: AsyncSession, item: PrintQueueItem) -> Path | None:
     """Resolve an existing queue item's source 3MF on disk, or None."""
     if item.archive_id:
@@ -622,35 +743,7 @@ async def add_to_queue(
         archive = result.scalar_one_or_none()
         if not archive:
             raise HTTPException(400, "Archive not found")
-        # IDOR fix (maziggy/bambuddy-security #2): without this check, a
-        # caller with QUEUE_CREATE could queue any user's archive even
-        # without ARCHIVES_READ on it — Landon's PoC enumerated this on
-        # admin's archives as operator1. Gate on ARCHIVES_READ_ALL OR
-        # ownership of the archive. 404 (not 403) so we don't leak
-        # "this id exists but you can't queue it" for enumeration.
-        if (
-            current_user is not None
-            and not current_user.has_permission(Permission.ARCHIVES_READ_ALL.value)
-            and archive.created_by_id != current_user.id
-        ):
-            raise HTTPException(404, "Archive not found")
-        # Reprint perm gate (#1625): the legacy /archives/{id}/reprint endpoint
-        # required ARCHIVES_REPRINT_OWN/ALL; the unified queue route must keep
-        # that gate or an operator with QUEUE_CREATE could reprint via direct
-        # API call even if explicitly denied reprint perm. Mirrors the
-        # frontend `canModify('archives', 'reprint', ...)` helper:
-        # REPRINT_ALL allows any archive, REPRINT_OWN allows own only,
-        # ownerless archives require REPRINT_ALL (fail-closed).
-        if current_user is not None:
-            owns_archive = archive.created_by_id is not None and archive.created_by_id == current_user.id
-            has_reprint = current_user.has_permission(Permission.ARCHIVES_REPRINT_ALL.value) or (
-                owns_archive and current_user.has_permission(Permission.ARCHIVES_REPRINT_OWN.value)
-            )
-            if not has_reprint:
-                raise HTTPException(
-                    status_code=403,
-                    detail="Permission archives:reprint_own or archives:reprint_all required",
-                )
+        _assert_can_queue_archive(archive, current_user)
 
     # Validate library file exists (if provided) and get it for filament extraction
     library_file = None
@@ -659,13 +752,7 @@ async def add_to_queue(
         library_file = result.scalar_one_or_none()
         if not library_file:
             raise HTTPException(400, "Library file not found")
-        # Same shape: gate cross-user library-file queueing on LIBRARY_READ_ALL.
-        if (
-            current_user is not None
-            and not current_user.has_permission(Permission.LIBRARY_READ_ALL.value)
-            and library_file.created_by_id != current_user.id
-        ):
-            raise HTTPException(404, "Library file not found")
+        _assert_can_queue_library_file(library_file, current_user)
         # Bambu SD card is FAT32/exFAT — illegal filename chars would 553 at
         # FTP upload time (#1540). Reject at queue time so the user gets the
         # actionable error before waiting in queue.
@@ -1078,6 +1165,66 @@ async def bulk_update_queue_items(
 # --- Batch endpoints ---
 
 
+def _validate_plate_targets(
+    plates: list[PrintBatchPlateTarget] | None,
+) -> list[PrintBatchPlateTarget] | None:
+    """Reject duplicate plates and orders that ask for nothing at all.
+
+    A duplicate would violate the (batch_id, plate_id) unique constraint at
+    flush time — and on SQLite/PostgreSQL a NULL plate_id slips past that
+    constraint entirely, so the check has to happen here to catch two
+    "whole file" rows in one order.
+    """
+    if plates is None:
+        return None
+    if not plates:
+        raise HTTPException(400, "plates must contain at least one plate")
+
+    seen: set[int | None] = set()
+    for target in plates:
+        if target.plate_id in seen:
+            label = target.plate_id if target.plate_id is not None else "whole file"
+            raise HTTPException(400, f"Duplicate plate in order: {label}")
+        seen.add(target.plate_id)
+
+    if all(target.quantity_target == 0 for target in plates):
+        raise HTTPException(400, "Order must request at least one print")
+    return plates
+
+
+async def _validate_batch_project(db: AsyncSession, project_id: int | None, current_user: User | None) -> None:
+    """404 on a bogus project id rather than letting the FK blow up as a 500."""
+    if project_id is None:
+        return
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(404, "Project not found")
+
+
+async def _load_batch_for_write(
+    db: AsyncSession, batch_id: int, current_user: User | None, permission: Permission
+) -> PrintBatch:
+    """Fetch a batch the caller is allowed to modify, or 404.
+
+    404 rather than 403 on the ownership miss, matching the rest of this
+    module: a 403 would confirm the id exists to someone enumerating.
+    """
+    result = await db.execute(
+        select(PrintBatch).options(selectinload(PrintBatch.plates)).where(PrintBatch.id == batch_id)
+    )
+    batch = result.scalar_one_or_none()
+    if not batch:
+        raise HTTPException(404, "Batch not found")
+    if (
+        current_user is not None
+        and batch.created_by_id is not None
+        and batch.created_by_id != current_user.id
+        and not current_user.has_permission(permission.value)
+    ):
+        raise HTTPException(404, "Batch not found")
+    return batch
+
+
 @router.post("/batches", response_model=PrintBatchResponse)
 async def create_batch(
     data: PrintBatchCreate,
@@ -1092,10 +1239,18 @@ async def create_batch(
     * ``item_ids`` omitted/empty: create an empty batch so the client can
       pass the returned ``id`` on subsequent ``POST /queue/`` calls. Used by
       the multi-plate auto-batch flow in PrintModal.
+
+    ``plates`` turns the batch into an order with per-plate targets (#342):
+    progress is then measured against what was asked for rather than against
+    what happened to be queued, so a failed run still counts as owed. Omitting
+    it keeps the pre-#342 behaviour exactly.
     """
     if not data.name or not data.name.strip():
         raise HTTPException(400, "Batch name is required")
 
+    plate_targets = _validate_plate_targets(data.plates)
+    await _validate_batch_project(db, data.project_id, current_user)
+
     batch = PrintBatch(
         name=data.name.strip()[:255],
         archive_id=data.archive_id,
@@ -1103,10 +1258,28 @@ async def create_batch(
         quantity=len(data.item_ids) if data.item_ids else 1,
         status="active",
         created_by_id=current_user.id if current_user else None,
+        project_id=data.project_id,
+        due_date=data.due_date,
+        notes=data.notes,
     )
     db.add(batch)
     await db.flush()  # Need batch.id before assigning to items
 
+    if plate_targets is not None:
+        for target in plate_targets:
+            db.add(
+                PrintBatchPlate(
+                    batch_id=batch.id,
+                    plate_id=target.plate_id,
+                    plate_name=target.plate_name,
+                    quantity_target=target.quantity_target,
+                    sort_order=target.sort_order,
+                )
+            )
+        # The legacy `quantity` column is display-only; keep it meaningful for
+        # anything still reading it by making it the order's total.
+        batch.quantity = max(1, sum(t.quantity_target for t in plate_targets))
+
     assigned = 0
     if data.item_ids:
         result = await db.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_(data.item_ids)))
@@ -1133,6 +1306,116 @@ async def create_batch(
     return await _build_batch_response(db, batch)
 
 
+@router.patch("/batches/{batch_id}", response_model=PrintBatchResponse)
+async def update_batch(
+    batch_id: int,
+    data: PrintBatchUpdate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_UPDATE_OWN),
+):
+    """Edit an order's header or its per-plate targets while it runs (#342).
+
+    Production requirements change mid-run, so targets are editable. Lowering a
+    target below what has already been dispatched is allowed and simply leaves
+    ``remaining`` at zero — cancelling the surplus queue items is a separate,
+    explicit action, because silently deleting queued work on a number change
+    would be a nasty surprise.
+    """
+    batch = await _load_batch_for_write(db, batch_id, current_user, Permission.QUEUE_UPDATE_ALL)
+
+    plate_targets = _validate_plate_targets(data.plates)
+    if data.project_id is not None:
+        await _validate_batch_project(db, data.project_id, current_user)
+
+    if data.name is not None:
+        if not data.name.strip():
+            raise HTTPException(400, "Batch name is required")
+        batch.name = data.name.strip()[:255]
+    if data.project_id is not None:
+        batch.project_id = data.project_id
+    if data.due_date is not None:
+        batch.due_date = data.due_date
+    if data.notes is not None:
+        batch.notes = data.notes
+    if data.status is not None:
+        batch.status = data.status
+
+    if plate_targets is not None:
+        existing = {row.plate_id: row for row in batch.plates}
+        for target in plate_targets:
+            row = existing.pop(target.plate_id, None)
+            if row is None:
+                db.add(
+                    PrintBatchPlate(
+                        batch_id=batch.id,
+                        plate_id=target.plate_id,
+                        plate_name=target.plate_name,
+                        quantity_target=target.quantity_target,
+                        sort_order=target.sort_order,
+                    )
+                )
+            else:
+                row.quantity_target = target.quantity_target
+                row.sort_order = target.sort_order
+                if target.plate_name is not None:
+                    row.plate_name = target.plate_name
+        # Plates absent from the payload are dropped — the list is the order.
+        for orphan in existing.values():
+            await db.delete(orphan)
+        batch.quantity = max(1, sum(t.quantity_target for t in plate_targets))
+
+    await db.flush()
+    await db.refresh(batch)
+    # Raising a target on a finished order reopens it; lowering one on a
+    # running order can complete it.
+    await refresh_batch_status(db, batch)
+    await db.commit()
+    await db.refresh(batch)
+
+    logger.info("Updated batch %s", batch.id)
+    return await _build_batch_response(db, batch)
+
+
+@router.post("/batches/{batch_id}/dispatch", response_model=PrintBatchResponse)
+async def dispatch_batch(
+    batch_id: int,
+    data: PrintBatchDispatchRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.QUEUE_CREATE),
+):
+    """Queue the runs this order still owes (#342).
+
+    Each new item is cloned from the most recent item for the same plate in
+    this batch, so it inherits the printer/model target, AMS mapping, filament
+    overrides and print options the user already chose — and the validation
+    those went through at creation time.
+    """
+    batch = await _load_batch_for_write(db, batch_id, current_user, Permission.QUEUE_UPDATE_ALL)
+    if batch.status == "cancelled":
+        raise HTTPException(400, "Cannot dispatch a cancelled batch")
+
+    # Dispatch starts prints, so it must not be a weaker door than POST /queue/.
+    await _assert_can_dispatch_batch_sources(db, batch.id, current_user)
+
+    try:
+        created = await dispatch_remaining(
+            db,
+            batch,
+            plate_id=data.plate_id,
+            only_plate=data.only_plate,
+            limit=data.limit,
+            created_by_id=current_user.id if current_user else None,
+        )
+    except BatchDispatchError as exc:
+        raise HTTPException(400, str(exc)) from exc
+
+    await db.commit()
+    await db.refresh(batch)
+
+    logger.info("Batch %s dispatched %d item(s)", batch.id, len(created))
+    return await _build_batch_response(db, batch)
+
+
 @router.post("/batches/{batch_id}/ungroup", response_model=PrintBatchUngroupResponse)
 async def ungroup_batch(
     batch_id: int,
@@ -1189,9 +1472,23 @@ async def list_batches(
         )
     ),
 ):
-    """List all print batches with progress stats."""
+    """List print batches with progress stats.
+
+    Batches with neither queue items nor per-plate targets are omitted. Those
+    are empty shells — a grouping whose items were deleted with their source
+    archive, or a create that never got as far as adding any — and they carry
+    nothing to show, track or dispatch. A brand-new order is still listed
+    before its first dispatch, because its targets say what it owes.
+    """
     current_user, can_read_all = auth_result
-    query = select(PrintBatch).order_by(PrintBatch.created_at.desc())
+    query = (
+        select(PrintBatch)
+        .where(
+            select(PrintQueueItem.id).where(PrintQueueItem.batch_id == PrintBatch.id).exists()
+            | select(PrintBatchPlate.id).where(PrintBatchPlate.batch_id == PrintBatch.id).exists()
+        )
+        .order_by(PrintBatch.created_at.desc())
+    )
     if status:
         query = query.where(PrintBatch.status == status)
     if current_user is not None and not can_read_all:
@@ -1199,10 +1496,14 @@ async def list_batches(
     result = await db.execute(query)
     batches = result.scalars().all()
 
-    responses = []
-    for batch in batches:
-        responses.append(await _build_batch_response(db, batch))
-    return responses
+    # Resolve creator names in one query rather than one per batch.
+    creator_ids = {b.created_by_id for b in batches if b.created_by_id is not None}
+    usernames: dict[int, str] = {}
+    if creator_ids:
+        rows = await db.execute(select(User.id, User.username).where(User.id.in_(creator_ids)))
+        usernames = {row[0]: row[1] for row in rows.all()}
+
+    return [await _build_batch_response(db, batch, usernames=usernames) for batch in batches]
 
 
 @router.get("/batches/{batch_id}", response_model=PrintBatchResponse)
@@ -1259,23 +1560,25 @@ async def cancel_batch(
     return {"message": f"Batch cancelled, {cancelled_count} pending items cancelled"}
 
 
-async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBatchResponse:
-    """Build a batch response with derived counts from queue items."""
-    # Count queue items by status
-    result = await db.execute(
-        select(PrintQueueItem.status, func.count(PrintQueueItem.id))
-        .where(PrintQueueItem.batch_id == batch.id)
-        .group_by(PrintQueueItem.status)
-    )
-    status_counts = {row[0]: row[1] for row in result.fetchall()}
+async def _build_batch_response(
+    db: AsyncSession, batch: PrintBatch, *, usernames: dict[int, str] | None = None
+) -> PrintBatchResponse:
+    """Build a batch response with per-plate progress derived from queue items.
+
+    ``usernames`` lets the list endpoint resolve every creator in one query
+    instead of one per batch.
+    """
+    progress = await load_progress(db, batch)
 
-    # Load created_by for username
     created_by_username = None
     if batch.created_by_id:
-        result = await db.execute(select(User).where(User.id == batch.created_by_id))
-        user = result.scalar_one_or_none()
-        if user:
-            created_by_username = user.username
+        if usernames is not None:
+            created_by_username = usernames.get(batch.created_by_id)
+        else:
+            result = await db.execute(select(User).where(User.id == batch.created_by_id))
+            user = result.scalar_one_or_none()
+            if user:
+                created_by_username = user.username
 
     return PrintBatchResponse(
         id=batch.id,
@@ -1285,13 +1588,45 @@ async def _build_batch_response(db: AsyncSession, batch: PrintBatch) -> PrintBat
         quantity=batch.quantity,
         status=batch.status,
         created_at=batch.created_at,
+        completed_at=batch.completed_at,
         created_by_id=batch.created_by_id,
         created_by_username=created_by_username,
-        pending_count=status_counts.get("pending", 0),
-        printing_count=status_counts.get("printing", 0),
-        completed_count=status_counts.get("completed", 0),
-        failed_count=status_counts.get("failed", 0),
-        cancelled_count=status_counts.get("cancelled", 0),
+        project_id=batch.project_id,
+        due_date=batch.due_date,
+        notes=batch.notes,
+        pending_count=progress.pending,
+        printing_count=progress.printing,
+        completed_count=progress.completed,
+        failed_count=progress.failed,
+        cancelled_count=progress.cancelled,
+        skipped_count=progress.skipped,
+        has_targets=progress.has_targets,
+        target_count=progress.target,
+        remaining_count=progress.remaining,
+        actual_cost=progress.actual_cost,
+        estimated_remaining_cost=progress.estimated_remaining_cost,
+        filament_used_grams=progress.filament_used_grams,
+        print_time_seconds=progress.print_time_seconds,
+        plates=[
+            PrintBatchPlateProgress(
+                plate_id=plate.plate_id,
+                plate_name=plate.plate_name,
+                quantity_target=plate.quantity_target,
+                dispatched=plate.dispatched,
+                remaining=plate.remaining,
+                pending_count=plate.pending,
+                printing_count=plate.printing,
+                completed_count=plate.completed,
+                failed_count=plate.failed,
+                cancelled_count=plate.cancelled,
+                skipped_count=plate.skipped,
+                actual_cost=plate.actual_cost,
+                estimated_remaining_cost=plate.estimated_remaining_cost,
+                filament_used_grams=plate.filament_used_grams,
+                print_time_seconds=plate.print_time_seconds,
+            )
+            for plate in progress.plates
+        ],
     )
 
 

+ 17 - 13
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,6 +62,7 @@ from backend.app.services.printer_manager import (
     supports_chamber_temp,
     supports_drying,
     supports_drying_while_printing,
+    uniform_tray_drying_hint,
 )
 from backend.app.utils.filament_ids import filament_id_to_setting_id
 from backend.app.utils.http import build_content_disposition
@@ -576,20 +578,18 @@ 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.
+            # Fallback: derive 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 on a filament
+            # type. See uniform_tray_drying_hint.
             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
+                hint_filament, hint_temp = uniform_tray_drying_hint(
+                    [(tray.tray_type or "", tray.drying_temp) for tray in trays]
+                )
+                if not dry_filament:
+                    dry_filament = hint_filament
+                if dry_target_temp is None:
+                    dry_target_temp = hint_temp
 
             ams_units.append(
                 AMSUnit(
@@ -869,6 +869,7 @@ async def get_overlay_status(
             "layer_num": None,
             "total_layers": None,
             "stg_cur_name": None,
+            "temperatures": {},
             "time_format": time_format,
         }
 
@@ -885,6 +886,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,
     }
 

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

@@ -2618,6 +2618,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")

+ 40 - 1
backend/app/main.py

@@ -5070,6 +5070,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)
@@ -5436,6 +5448,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,
@@ -7004,6 +7020,17 @@ async def lifespan(app: FastAPI):
     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
@@ -7672,6 +7699,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}'; "
@@ -7682,7 +7721,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"

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

@@ -19,7 +19,7 @@ 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_sensor_history import PrinterSensorHistory
 from backend.app.models.project import Project
@@ -59,6 +59,7 @@ __all__ = [
     "AmsLabel",
     "PendingUpload",
     "PrintBatch",
+    "PrintBatchPlate",
     "LibraryFolder",
     "LibraryFile",
     "FileVariantGroup",

+ 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)

+ 87 - 0
backend/app/schemas/print_queue.py

@@ -331,6 +331,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")."""
@@ -342,6 +356,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):
@@ -351,6 +400,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."""
 
@@ -361,14 +432,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

+ 65 - 0
backend/app/services/bambu_mqtt.py

@@ -40,6 +40,12 @@ _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
+
 # 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:
@@ -1642,6 +1648,44 @@ class BambuMQTTClient:
                         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.
@@ -2751,6 +2795,27 @@ class BambuMQTTClient:
                 current = int(raw_dry_time)
             except (TypeError, ValueError):
                 continue
+            # 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,
+                    ams_unit.get("dry_status"),
+                )
+                continue
             previous = self._previous_dry_times.get(ams_id, 0)
             self._previous_dry_times[ams_id] = current
             if previous > 0 and current == 0:

+ 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,

+ 81 - 9
backend/app/services/print_scheduler.py

@@ -324,6 +324,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).
 
@@ -2713,10 +2741,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:
@@ -4122,6 +4158,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)
@@ -4152,6 +4193,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
@@ -4188,6 +4230,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):
@@ -4198,6 +4241,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
@@ -4249,11 +4303,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 db.commit()
                 return "gave_up"

+ 88 - 13
backend/app/services/printer_manager.py

@@ -238,6 +238,85 @@ 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_drying_hint(loaded_trays: list[tuple[str, object]]) -> tuple[str | None, int | None]:
+    """Guess an active cycle's filament + target temperature 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). Saying
+    nothing and letting the badge show just the countdown beats stating a
+    temperature the cycle isn't using.
+
+    Args:
+        loaded_trays: ``(tray_type, drying_temp)`` for each tray, in slot order.
+            Empty slots (falsy tray_type) are ignored. ``drying_temp`` is the
+            RFID-recommended value and may be None or unparseable.
+
+    Returns:
+        ``(filament, temp)``, either of which may be None.
+    """
+    types = {str(tray_type) for tray_type, _ in loaded_trays if tray_type}
+    if len(types) != 1:
+        return None, None
+    filament = next(iter(types))
+    for tray_type, drying_temp in loaded_trays:
+        if not tray_type or not drying_temp:
+            continue
+        try:
+            return filament, int(drying_temp)
+        except (TypeError, ValueError):
+            continue
+    return filament, None
+
+
 def supports_drying(model: str | None, firmware: str | None) -> bool:
     """Check if a printer model accepts remote AMS drying commands.
 
@@ -1254,9 +1333,8 @@ 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), fall back to the loaded trays — but only when
+            # they agree on a filament type. See uniform_tray_drying_hint.
             ams_id_int = int(ams_data.get("id", 0))
             target = (drying_targets or {}).get(ams_id_int)
             dry_target_temp: int | None = None
@@ -1272,16 +1350,13 @@ def printer_state_to_dict(
                 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
+                hint_filament, hint_temp = uniform_tray_drying_hint(
+                    [(tray.get("tray_type") or "", tray.get("drying_temp")) for tray in trays]
+                )
+                if not dry_filament:
+                    dry_filament = hint_filament
+                if dry_target_temp is None:
+                    dry_target_temp = hint_temp
 
             ams_units.append(
                 {

+ 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

+ 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

+ 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):

+ 161 - 0
backend/tests/unit/services/test_bambu_mqtt.py

@@ -6078,6 +6078,48 @@ 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]
+
 
 class TestPrintRunningObservedCallback:
     """#1485 follow-up: on_print_running_observed fires the FIRST time we
@@ -7451,3 +7493,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

+ 111 - 2
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,
@@ -1378,8 +1379,8 @@ class TestDryingTargetExposure:
         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)."""
+        """No cached target → derive from the loaded trays' tray_type +
+        RFID-recommended drying_temp when they agree on a filament."""
         state = self._state_with_ams(
             {
                 "id": 0,
@@ -1419,6 +1420,114 @@ 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 — a unit loaded entirely
+        with PLA keeps the fallback 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"
+        assert result["ams"][0]["dry_target_temp"] == 45
+
+    def test_fallback_takes_temp_from_a_later_tray_when_slot_one_has_none(self):
+        """Only Bambu spools carry an RFID drying_temp. A third-party spool in
+        slot 1 alongside a genuine one of the same type should not cost us the
+        temperature."""
+        state = self._state_with_ams(
+            {
+                "id": 0,
+                "dry_time": 719,
+                "tray": [
+                    {"id": 0, "tray_type": "PLA", "state": 11},
+                    {"id": 1, "tray_type": "PLA", "drying_temp": 45, "state": 11},
+                ],
+            }
+        )
+        result = printer_state_to_dict(state, drying_targets={})
+        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."""

+ 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

+ 185 - 0
backend/tests/unit/test_scheduler_watchdog.py

@@ -13,6 +13,7 @@ belt-and-braces for slow transitions that also don't emit an early subtask_id
 tick.
 """
 
+import itertools
 from types import SimpleNamespace
 from unittest.mock import AsyncMock, MagicMock, patch
 
@@ -722,3 +723,187 @@ class TestWatchdogCommandRejected:
             item = await db.get(PrintQueueItem, 1)
             assert item.status == "printing"
             assert item.dispatch_attempts == 0
+
+
+def _drying_status(state: str, subtask_id: str | None = None, *, drying: dict[int, int] | None = None, **kw):
+    """``_status`` plus the ``raw_data['ams']`` shape the drying probe reads.
+
+    ``drying`` maps AMS unit id -> dry_time in minutes (0 = idle unit).
+    """
+    st = _status(state, subtask_id, **kw)
+    st.raw_data = {"ams": [{"id": i, "dry_time": t} for i, t in (drying or {}).items()]}
+    return st
+
+
+class TestDryingAmsIds:
+    """``_drying_ams_ids`` is a diagnostic read of firmware telemetry (#2758)."""
+
+    def test_reports_units_with_time_remaining(self):
+        from backend.app.services.print_scheduler import _drying_ams_ids
+
+        assert _drying_ams_ids(_drying_status("IDLE", drying={0: 45, 1: 0, 128: 12})) == [0, 128]
+
+    def test_no_raw_data_is_not_an_error(self):
+        """Every watchdog poll calls this, including against the bare status
+        objects other tests build, so a missing field must read as 'not drying'
+        rather than raise inside the dispatch loop."""
+        from backend.app.services.print_scheduler import _drying_ams_ids
+
+        assert _drying_ams_ids(_status("IDLE")) == []
+        assert _drying_ams_ids(SimpleNamespace(raw_data={})) == []
+
+    def test_unparseable_entries_are_skipped_not_fatal(self):
+        from backend.app.services.print_scheduler import _drying_ams_ids
+
+        status = SimpleNamespace(raw_data={"ams": ["nonsense", {"id": 2, "dry_time": "20"}, {"dry_time": None}]})
+        assert _drying_ams_ids(status) == [2]
+
+
+class TestWatchdogNamesDryingAsTheObstacle:
+    """#2758: an X2D with two AMS units drying accepted the file and never
+    started. The watchdog waited out both phases three times, re-uploading the
+    whole 3MF each lap, and closed with a message about the SD card — while the
+    actual obstacle was on the printer's own screen the whole time.
+
+    Detection only. Bambuddy does not stop the cycle: this hardware supports
+    drying concurrently with an active print, so drying is not incompatible with
+    printing, and it is not yet established whether the blocker is the drying or
+    the power budget of an AMS drying without its external PSU.
+    """
+
+    @staticmethod
+    async def _wedge_while_drying(db_session, *, drying: dict[int, int], item_id: int = 1):
+        get_status = MagicMock(return_value=_drying_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf", drying=drying))
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.printer_manager.get_client", MagicMock()),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+            patch("backend.app.core.database.async_session", db_session),
+            patch(
+                "backend.app.services.notification_service.notification_service.on_queue_job_failed",
+                AsyncMock(),
+            ),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=item_id,
+                printer_id=42,
+                pre_state="IDLE",
+                pre_subtask_id="OLD_SUBTASK",
+                pre_gcode_file="/old.3mf",
+                timeout=0.2,
+                phase_b_timeout=0.2,
+                poll_interval=0.05,
+            )
+
+    @pytest.mark.asyncio
+    async def test_give_up_message_names_the_drying_units(self, db_session):
+        for _ in range(DISPATCH_MAX_ATTEMPTS):
+            async with db_session() as db:
+                item = await db.get(PrintQueueItem, 1)
+                item.status = "printing"
+                await db.commit()
+            await self._wedge_while_drying(db_session, drying={0: 45, 128: 12})
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+        assert item.status == "failed"
+        assert "AMS 0, AMS 128" in item.error_message
+        assert "were drying" in item.error_message
+        # The old text sent the reporter to check the SD card. It must not be
+        # what a drying-blocked dispatch says.
+        assert "SD card" not in item.error_message
+
+    @pytest.mark.asyncio
+    async def test_single_unit_reads_naturally(self, db_session):
+        for _ in range(DISPATCH_MAX_ATTEMPTS):
+            async with db_session() as db:
+                item = await db.get(PrintQueueItem, 1)
+                item.status = "printing"
+                await db.commit()
+            await self._wedge_while_drying(db_session, drying={128: 30})
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+        assert "AMS 128 was drying" in item.error_message
+
+    @pytest.mark.asyncio
+    async def test_no_drying_keeps_the_original_message(self, db_session):
+        """The generic advice is still right when drying had nothing to do with
+        it — this must not become the answer to every stalled dispatch."""
+        for _ in range(DISPATCH_MAX_ATTEMPTS):
+            async with db_session() as db:
+                item = await db.get(PrintQueueItem, 1)
+                item.status = "printing"
+                await db.commit()
+            await self._wedge_while_drying(db_session, drying={0: 0})
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+        assert item.status == "failed"
+        assert "SD card" in item.error_message
+        assert "drying" not in item.error_message
+
+    @pytest.mark.asyncio
+    async def test_a_cycle_that_ends_mid_window_is_still_reported(self, db_session):
+        """Latched, not level-tested. Drying finishing (or the user stopping it)
+        part-way through the dispatch window must not erase the fact that it was
+        what the printer was doing when it declined to start."""
+        drying = _drying_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf", drying={1: 5})
+        finished = _drying_status("IDLE", "NEW_SUBTASK", gcode_file="/new.3mf", drying={1: 0})
+
+        for _ in range(DISPATCH_MAX_ATTEMPTS):
+            async with db_session() as db:
+                item = await db.get(PrintQueueItem, 1)
+                item.status = "printing"
+                await db.commit()
+            # Fresh per run: the first poll of each dispatch window sees the
+            # cycle, every later poll sees it finished.
+            get_status = MagicMock(side_effect=itertools.chain([drying], itertools.repeat(finished)))
+            with (
+                patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+                patch("backend.app.services.print_scheduler.printer_manager.get_client", MagicMock()),
+                patch("backend.app.services.print_scheduler.async_session", db_session),
+                patch("backend.app.core.database.async_session", db_session),
+                patch(
+                    "backend.app.services.notification_service.notification_service.on_queue_job_failed",
+                    AsyncMock(),
+                ),
+            ):
+                await PrintScheduler._watchdog_print_start(
+                    queue_item_id=1,
+                    printer_id=42,
+                    pre_state="IDLE",
+                    pre_subtask_id="OLD_SUBTASK",
+                    pre_gcode_file="/old.3mf",
+                    timeout=0.2,
+                    phase_b_timeout=0.2,
+                    poll_interval=0.05,
+                )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+        assert "AMS 1 was drying" in item.error_message
+
+    @pytest.mark.asyncio
+    async def test_drying_does_not_make_a_successful_start_fail(self, db_session):
+        """Drying is not an error condition. A printer that starts the job while
+        an AMS dries — which this hardware supports — must be left alone."""
+        get_status = MagicMock(return_value=_drying_status("RUNNING", "NEW_SUBTASK", drying={0: 45}))
+        with (
+            patch("backend.app.services.print_scheduler.printer_manager.get_status", get_status),
+            patch("backend.app.services.print_scheduler.async_session", db_session),
+            patch("backend.app.core.database.async_session", db_session),
+        ):
+            await PrintScheduler._watchdog_print_start(
+                queue_item_id=1,
+                printer_id=42,
+                pre_state="IDLE",
+                pre_subtask_id="OLD_SUBTASK",
+                timeout=0.3,
+                poll_interval=0.05,
+            )
+
+        async with db_session() as db:
+            item = await db.get(PrintQueueItem, 1)
+        assert item.status == "printing"
+        assert (item.dispatch_attempts or 0) == 0

+ 8 - 0
deploy/bambuddy.service

@@ -62,6 +62,14 @@ StandardOutput=journal
 StandardError=journal
 SyslogIdentifier=bambuddy
 
+# Allow binding to privileged ports (322 RTSP, 990 FTPS) for Virtual Printer
+# mode. Without this the VP's sockets never open and the slicer simply never
+# sees the printer — with no obvious error, since the bind failure is one line
+# in the journal (#2549). Works alongside NoNewPrivileges=true below: systemd
+# raises the ambient set at exec, which is not the privilege escalation that
+# setting forbids.
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+
 # Security hardening
 NoNewPrivileges=true
 PrivateTmp=true

+ 229 - 0
frontend/src/__tests__/components/BatchOrdersView.test.tsx

@@ -0,0 +1,229 @@
+/**
+ * Tests for the Batch Orders tab (#342).
+ *
+ * The point of this view is the gap the Queue and History tabs cannot show:
+ * what an order asked for versus what has actually been produced, including
+ * the runs that failed and are therefore still owed.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { BatchOrdersView } from '../../components/BatchOrdersView';
+import type { PrintBatch, PrintBatchPlateProgress } from '../../api/client';
+
+const plate = (over: Partial<PrintBatchPlateProgress> = {}): PrintBatchPlateProgress => ({
+  plate_id: 1,
+  plate_name: null,
+  quantity_target: 1,
+  dispatched: 1,
+  remaining: 0,
+  pending_count: 0,
+  printing_count: 0,
+  completed_count: 1,
+  failed_count: 0,
+  cancelled_count: 0,
+  skipped_count: 0,
+  actual_cost: null,
+  estimated_remaining_cost: null,
+  filament_used_grams: null,
+  print_time_seconds: 0,
+  ...over,
+});
+
+const batch = (over: Partial<PrintBatch> = {}): PrintBatch => ({
+  id: 1,
+  name: 'Widget run',
+  archive_id: 7,
+  library_file_id: null,
+  quantity: 6,
+  status: 'active',
+  created_at: '2026-08-01T10:00:00Z',
+  completed_at: null,
+  created_by_id: null,
+  created_by_username: null,
+  project_id: null,
+  due_date: null,
+  notes: null,
+  pending_count: 0,
+  printing_count: 0,
+  completed_count: 0,
+  failed_count: 0,
+  cancelled_count: 0,
+  skipped_count: 0,
+  has_targets: true,
+  target_count: 6,
+  remaining_count: 6,
+  actual_cost: null,
+  estimated_remaining_cost: null,
+  filament_used_grams: null,
+  print_time_seconds: 0,
+  plates: [],
+  ...over,
+});
+
+const allow = () => true;
+const deny = () => false;
+const passthroughT = (key: string, options?: Record<string, unknown>) => {
+  void options;
+  return key;
+};
+
+describe('BatchOrdersView (#342)', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/settings/', () => HttpResponse.json({ currency: 'EUR' })),
+      http.get('/api/v1/queue/batches', () => HttpResponse.json([])),
+    );
+  });
+
+  it('shows the empty state when nothing matches the filter', async () => {
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+    await waitFor(() =>
+      expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument(),
+    );
+  });
+
+  it('surfaces a finished order that has no queue rows left', async () => {
+    // The case the Queue and History tabs each miss: every run completed, so
+    // nothing is pending, yet the order is the thing the user wants to see.
+    server.use(
+      http.get('/api/v1/queue/batches', ({ request }) => {
+        const status = new URL(request.url).searchParams.get('status');
+        if (status !== 'completed') return HttpResponse.json([]);
+        return HttpResponse.json([
+          batch({ status: 'completed', completed_count: 6, remaining_count: 0, completed_at: '2026-08-02T10:00:00Z' }),
+        ]);
+      }),
+    );
+    const user = userEvent.setup();
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('queue.batchOrders.emptyTitle')).toBeInTheDocument());
+    await user.click(screen.getByRole('button', { name: 'queue.batchOrders.filter.completed' }));
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    expect(screen.getByText('queue.batchOrders.status.completed')).toBeInTheDocument();
+  });
+
+  it('offers to queue what a failed run still owes', async () => {
+    let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({
+            completed_count: 1,
+            failed_count: 1,
+            target_count: 2,
+            remaining_count: 1,
+            plates: [plate({ quantity_target: 2, dispatched: 1, remaining: 1, completed_count: 1, failed_count: 1 })],
+          }),
+        ]),
+      ),
+      http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
+        dispatched = (await request.json()) as { plate_id?: number | null };
+        return HttpResponse.json(batch({ remaining_count: 0, pending_count: 1 }));
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    // Reported at both levels: the order summary and the plate that burned.
+    expect(screen.getAllByText('queue.batchOrders.failed')).toHaveLength(2);
+
+    await user.click(screen.getByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }));
+    await waitFor(() => expect(dispatched).not.toBeNull());
+    // Order-level dispatch covers every plate, so no plate filter is sent.
+    expect(dispatched).toEqual({});
+  });
+
+  it('dispatches a single plate from its own row', async () => {
+    let dispatched: { plate_id?: number | null; only_plate?: boolean } | null = null;
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({
+            target_count: 4,
+            remaining_count: 3,
+            plates: [
+              plate({ plate_id: 1, quantity_target: 1, remaining: 0 }),
+              plate({ plate_id: 2, quantity_target: 3, dispatched: 0, completed_count: 0, remaining: 3 }),
+            ],
+          }),
+        ]),
+      ),
+      http.post('/api/v1/queue/batches/:id/dispatch', async ({ request }) => {
+        dispatched = (await request.json()) as { plate_id?: number | null };
+        return HttpResponse.json(batch());
+      }),
+    );
+
+    const user = userEvent.setup();
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    // Only the plate with work outstanding offers the action.
+    const plateButtons = screen.getAllByRole('button', { name: 'queue.batchOrders.dispatchPlate' });
+    expect(plateButtons).toHaveLength(1);
+
+    await user.click(plateButtons[0]);
+    await waitFor(() => expect(dispatched).not.toBeNull());
+    expect(dispatched).toEqual({ plate_id: 2, only_plate: true });
+  });
+
+  it('marks a legacy batch as grouping-only and offers no dispatch', async () => {
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({ has_targets: false, target_count: 3, remaining_count: 0, pending_count: 3, plates: [] }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('queue.batchOrders.noTargets')).toBeInTheDocument());
+    expect(
+      screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
+    ).not.toBeInTheDocument();
+  });
+
+  it('hides the dispatch and cancel actions without permission', async () => {
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({ pending_count: 2, remaining_count: 2, plates: [plate({ quantity_target: 3, remaining: 2 })] }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={deny} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Widget run')).toBeInTheDocument());
+    expect(
+      screen.queryByRole('button', { name: /queue.batchOrders.dispatchRemaining/ }),
+    ).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: /queue.batchOrders.dispatchPlate/ })).not.toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'queue.cancelBatch' })).not.toBeInTheDocument();
+  });
+
+  it('shows cost only once a run has produced one', async () => {
+    server.use(
+      http.get('/api/v1/queue/batches', () =>
+        HttpResponse.json([
+          batch({ id: 1, name: 'Priced', actual_cost: 6, estimated_remaining_cost: 3, completed_count: 2 }),
+          batch({ id: 2, name: 'Unpriced', actual_cost: null, estimated_remaining_cost: null }),
+        ]),
+      ),
+    );
+    render(<BatchOrdersView hasPermission={allow} t={passthroughT} />);
+
+    await waitFor(() => expect(screen.getByText('Priced')).toBeInTheDocument());
+    // One cost line, belonging to the priced order — never a fabricated 0.00.
+    expect(screen.getAllByText('queue.batchOrders.costSoFar')).toHaveLength(1);
+  });
+});

+ 162 - 1
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -8,7 +8,7 @@
 
 import { describe, it, expect, vi, beforeEach } from 'vitest';
 import type React from 'react';
-import { screen, waitFor, render as rtlRender } from '@testing-library/react';
+import { screen, waitFor, fireEvent, render as rtlRender } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
 import { BrowserRouter } from 'react-router-dom';
@@ -1988,3 +1988,164 @@ describe('PrintModal — per-plate filament override in model mode (#2552)', ()
     ]);
   });
 });
+
+describe('PrintModal — per-plate quantity (#342)', () => {
+  const mockOnClose = vi.fn();
+
+  const PLATES = {
+    is_multi_plate: true,
+    plates: [
+      { index: 1, name: 'Plate 1', has_thumbnail: false, thumbnail_url: null, objects: ['A'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+      { index: 2, name: 'Plate 2', has_thumbnail: false, thumbnail_url: null, objects: ['B'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+      { index: 3, name: 'Plate 3', has_thumbnail: false, thumbnail_url: null, objects: ['C'], filaments: [{ type: 'PLA', color: '#FF0000' }], print_time_seconds: 1800, filament_used_grams: 50 },
+    ],
+  };
+
+  const renderModal = (ui: React.ReactElement) => {
+    const queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+    });
+    queryClient.setQueryData(['archive-plates', 1], PLATES);
+    return rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>{ui}</ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>,
+    );
+  };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json(mockPrinters)),
+      http.get('/api/v1/archives/:id/plates', () => HttpResponse.json(PLATES)),
+      http.get('/api/v1/archives/:id/filament-requirements', () =>
+        HttpResponse.json({ filaments: [{ slot_id: 1, type: 'PLA', color: '#FF0000', tray_info_idx: '', used_grams: 50 }] }),
+      ),
+      http.get('/api/v1/printers/available-filaments', () => HttpResponse.json([])),
+      http.post('/api/v1/queue/batches', () => HttpResponse.json({ id: 42, name: 'Order', status: 'active' })),
+      http.post('/api/v1/queue/', () => HttpResponse.json({ id: 1, status: 'pending' })),
+    );
+  });
+
+  const qtyInput = (plateName: string) =>
+    screen.getByRole('spinbutton', { name: new RegExp(plateName, 'i') });
+
+  it('replaces the single Quantity field with one control per selected plate', async () => {
+    const user = userEvent.setup();
+    renderModal(<PrintModal mode="create" archiveId={1} archiveName="Three.gcode.3mf" onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    // Plate 1 is auto-selected and already carries its own quantity control.
+    expect(qtyInput('Plate 1')).toBeInTheDocument();
+    // The global field is gone — two controls for the same number would be ambiguous.
+    expect(screen.queryByLabelText(/^Quantity$/i)).not.toBeInTheDocument();
+    // Unselected plates have no control.
+    expect(screen.queryByRole('spinbutton', { name: /Plate 2/i })).not.toBeInTheDocument();
+
+    await user.click(screen.getByText('Plate 2'));
+    await waitFor(() => expect(qtyInput('Plate 2')).toBeInTheDocument());
+  });
+
+  it('keeps the single Quantity field for a single-plate file', async () => {
+    const queryClient = new QueryClient({
+      defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+    });
+    queryClient.setQueryData(['archive-plates', 1], { is_multi_plate: false, plates: [] });
+    rtlRender(
+      <QueryClientProvider client={queryClient}>
+        <BrowserRouter>
+          <AuthProvider>
+            <ThemeProvider>
+              <ToastProvider>
+                <PrintModal mode="create" archiveId={1} archiveName="One.gcode.3mf" onClose={mockOnClose} />
+              </ToastProvider>
+            </ThemeProvider>
+          </AuthProvider>
+        </BrowserRouter>
+      </QueryClientProvider>,
+    );
+
+    await waitFor(() => expect(screen.getByLabelText(/^Quantity$/i)).toBeInTheDocument());
+  });
+
+  it('queues the reporter\'s example: plate 1 once, plate 2 twice, plate 3 three times', async () => {
+    type Queued = { plate_id: number | null; quantity?: number; batch_id?: number };
+    type OrderBody = { plates?: Array<{ plate_id: number | null; quantity_target: number }> };
+    const queued: Queued[] = [];
+    let order: OrderBody | null = null;
+    server.use(
+      http.post('/api/v1/queue/batches', async ({ request }) => {
+        order = (await request.json()) as OrderBody;
+        return HttpResponse.json({ id: 42, name: 'Order', status: 'active' });
+      }),
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    renderModal(<PrintModal mode="create" archiveId={1} archiveName="Three.gcode.3mf" onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    await user.click(screen.getByText('Plate 2'));
+    await user.click(screen.getByText('Plate 3'));
+
+    fireEvent.change(qtyInput('Plate 2'), { target: { value: '2' } });
+    fireEvent.change(qtyInput('Plate 3'), { target: { value: '3' } });
+
+    await waitFor(() => expect(screen.getByText(/6 runs in total/i)).toBeInTheDocument());
+
+    await user.click(screen.getAllByRole('button', { name: /X1 Carbon/i })[0]);
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(queued.length).toBe(3));
+
+    // The order records the intent, so a failed run still reads as owed.
+    expect(order!.plates).toEqual([
+      expect.objectContaining({ plate_id: 1, quantity_target: 1 }),
+      expect.objectContaining({ plate_id: 2, quantity_target: 2 }),
+      expect.objectContaining({ plate_id: 3, quantity_target: 3 }),
+    ]);
+
+    // ...and the runs are dispatched immediately, one call per plate carrying
+    // that plate's own count. Quantity 1 is left off the payload as before.
+    const byPlate = Object.fromEntries(queued.map((q) => [q.plate_id, q]));
+    expect(byPlate[1].quantity).toBeUndefined();
+    expect(byPlate[2].quantity).toBe(2);
+    expect(byPlate[3].quantity).toBe(3);
+    expect(queued.every((q) => q.batch_id === 42)).toBe(true);
+  });
+
+  it('does not multiply per-plate counts across a multi-printer fan-out', async () => {
+    type Queued = { plate_id: number | null; quantity?: number; printer_id: number | null };
+    const queued: Queued[] = [];
+    server.use(
+      http.post('/api/v1/queue/', async ({ request }) => {
+        queued.push((await request.json()) as Queued);
+        return HttpResponse.json({ id: queued.length, status: 'pending' });
+      }),
+    );
+
+    const user = userEvent.setup();
+    renderModal(<PrintModal mode="create" archiveId={1} archiveName="Three.gcode.3mf" onClose={mockOnClose} />);
+
+    await waitFor(() => expect(screen.getByText('Plate 2')).toBeInTheDocument());
+    await user.click(screen.getByText('Plate 2'));
+    fireEvent.change(qtyInput('Plate 2'), { target: { value: '4' } });
+
+    // Two printers: the printer count already answers "how many".
+    await user.click(screen.getAllByRole('button', { name: /X1 Carbon/i })[0]);
+    await user.click(screen.getAllByRole('button', { name: /P1S/i })[0]);
+    await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+    await waitFor(() => expect(queued.length).toBe(4)); // 2 plates × 2 printers
+    expect(queued.every((q) => q.quantity === undefined)).toBe(true);
+  });
+});

+ 156 - 0
frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx

@@ -0,0 +1,156 @@
+/**
+ * Tests for the streaming-overlay URL builder (#1422).
+ *
+ * The builder's whole output is a URL, so that is what these assert: the field
+ * order, what is omitted at its default, and that the preview does not open a
+ * camera stream until it is asked to.
+ */
+
+import { describe, it, expect, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { StreamOverlayBuilder } from '../../components/StreamOverlayBuilder';
+
+const printers = [
+  { id: 1, name: 'X1 Carbon', ip_address: '192.168.1.100', serial_number: '00M09A350100001', model: 'X1C' },
+  { id: 2, name: 'P1S', ip_address: '192.168.1.101', serial_number: '01P00A000000002', model: 'P1S' },
+];
+
+// The URL is rendered inside a <code>, so read it back the way a user would.
+function shownUrl(): string {
+  const code = document.querySelector('code');
+  return code?.textContent ?? '';
+}
+
+describe('StreamOverlayBuilder', () => {
+  beforeEach(() => {
+    server.use(http.get('/api/v1/printers', () => HttpResponse.json(printers)));
+  });
+
+  it('starts on the first printer with the overlay defaults', async () => {
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => {
+      expect(shownUrl()).toContain('/overlay/1');
+    });
+    // The same set parseConfig() defaults to, so the builder's starting point
+    // and a bare /overlay/1 render the same overlay. Emitted in the overlay's
+    // own top-to-bottom field order rather than parseConfig's listing order —
+    // ?show= is read with includes(), so order is free to be the stable one.
+    expect(shownUrl()).toContain('show=filename%2Cstatus%2Cprogress%2Clayers%2Ceta');
+    // Defaults are omitted rather than spelled out — a shorter URL to paste.
+    expect(shownUrl()).not.toContain('size=');
+    expect(shownUrl()).not.toContain('fps=');
+    expect(shownUrl()).not.toContain('camera=');
+    expect(shownUrl()).not.toContain('token=');
+  });
+
+  it('switches printer', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByLabelText('Printer')).toBeInTheDocument());
+    await user.selectOptions(screen.getByLabelText('Printer'), '2');
+
+    await waitFor(() => expect(shownUrl()).toContain('/overlay/2'));
+  });
+
+  it('adds a temperature field the URL did not have', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByLabelText('Nozzle')).toBeInTheDocument());
+    await user.click(screen.getByLabelText('Nozzle'));
+
+    await waitFor(() =>
+      expect(shownUrl()).toContain('show=filename%2Cstatus%2Cprogress%2Clayers%2Ceta%2Cnozzle'),
+    );
+  });
+
+  it('emits fields in the overlay order, not the order they were clicked', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    // "Printer name" is first in the overlay's own top-to-bottom order, so
+    // ticking it last must still put it at the front. Otherwise the same
+    // selection would produce a different URL depending on click order, and a
+    // scene file would stop being comparable to the one next to it.
+    await waitFor(() => expect(screen.getByLabelText('Printer name')).toBeInTheDocument());
+    await user.click(screen.getByLabelText('Printer name'));
+
+    await waitFor(() => expect(shownUrl()).toContain('show=printer%2Cfilename'));
+  });
+
+  it('drops a field when its box is cleared', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByLabelText('Layer count')).toBeInTheDocument());
+    await user.click(screen.getByLabelText('Layer count'));
+
+    await waitFor(() => expect(shownUrl()).not.toContain('layers'));
+    expect(shownUrl()).toContain('progress');
+  });
+
+  it('emits camera=false when the camera feed is switched off', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByLabelText('Camera feed')).toBeInTheDocument());
+    await user.click(screen.getByLabelText('Camera feed'));
+
+    await waitFor(() => expect(shownUrl()).toContain('camera=false'));
+  });
+
+  it('emits size and fps only when they differ from the defaults', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByLabelText('Text size')).toBeInTheDocument());
+    await user.selectOptions(screen.getByLabelText('Text size'), 'large');
+    await waitFor(() => expect(shownUrl()).toContain('size=large'));
+
+    await user.selectOptions(screen.getByLabelText('Text size'), 'medium');
+    await waitFor(() => expect(shownUrl()).not.toContain('size='));
+  });
+
+  it('appends a token and warns that the URL is now a key', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByLabelText(/token/i)).toBeInTheDocument());
+    expect(screen.queryByText(/This URL contains a token/)).not.toBeInTheDocument();
+
+    await user.type(screen.getByLabelText(/token/i), 'bblt_abc');
+
+    await waitFor(() => expect(shownUrl()).toContain('token=bblt_abc'));
+    expect(screen.getByText(/This URL contains a token/)).toBeInTheDocument();
+  });
+
+  it('opens no camera stream until the preview is asked for', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+
+    await waitFor(() => expect(screen.getByText('Show preview')).toBeInTheDocument());
+    // An always-on preview would hold a subscriber on the printer's single
+    // camera connection for as long as the settings tab stays open.
+    expect(document.querySelector('iframe')).toBeNull();
+
+    await user.click(screen.getByText('Show preview'));
+
+    await waitFor(() => expect(document.querySelector('iframe')).not.toBeNull());
+    expect(document.querySelector('iframe')?.getAttribute('src')).toContain('/overlay/1');
+  });
+
+  it('still builds a URL when the printer list cannot be loaded', async () => {
+    server.use(http.get('/api/v1/printers', () => HttpResponse.json({ detail: 'nope' }, { status: 500 })));
+    render(<StreamOverlayBuilder />);
+
+    // Falls back to printer 1 rather than rendering /overlay/null — the number
+    // is the one thing the user can fix by hand in the URL.
+    await waitFor(() => expect(shownUrl()).toContain('/overlay/1'));
+  });
+});

+ 62 - 1
frontend/src/__tests__/contexts/ToastContext.test.tsx

@@ -12,7 +12,7 @@
  * paths no-op instead of crashing.
  */
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
 import { act, render, renderHook } from '@testing-library/react';
 import { type ReactNode } from 'react';
 import { ToastProvider, useToast } from '../../contexts/ToastContext';
@@ -163,3 +163,64 @@ describe('ToastContext viewport suppression', () => {
     expect(toast?.style.maxWidth).toContain('safe-area-inset-right');
   });
 });
+
+describe('ToastContext auto-dismiss timing by type', () => {
+  // Errors and warnings carry more text than a success confirmation — a
+  // backend failure reason often runs to a couple of lines — so they hold
+  // for 6s while success/info keep the 3s default.
+  function TypedToastProbe({ type }: { type: 'success' | 'error' | 'warning' | 'info' }) {
+    const { showToast } = useToast();
+    return <button data-testid="show" onClick={() => showToast(`a ${type} message`, type)} />;
+  }
+
+  function showAndAdvance(
+    type: 'success' | 'error' | 'warning' | 'info',
+    ms: number,
+  ): boolean {
+    const { getByTestId, queryByText, unmount } = render(
+      <ToastProvider>
+        <TypedToastProbe type={type} />
+      </ToastProvider>
+    );
+    act(() => {
+      getByTestId('show').click();
+    });
+    // Present before any time passes, otherwise a "gone" assertion below
+    // would pass on a toast that never rendered.
+    expect(queryByText(`a ${type} message`)).not.toBeNull();
+    act(() => {
+      vi.advanceTimersByTime(ms);
+    });
+    const stillThere = queryByText(`a ${type} message`) !== null;
+    unmount();
+    return stillThere;
+  }
+
+  beforeEach(() => {
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('keeps error toasts up for 6s', () => {
+    // Just past the old 3s window — an error must still be readable here.
+    expect(showAndAdvance('error', 3100)).toBe(true);
+    expect(showAndAdvance('error', 5999)).toBe(true);
+    expect(showAndAdvance('error', 6000)).toBe(false);
+  });
+
+  it('keeps warning toasts up for 6s', () => {
+    expect(showAndAdvance('warning', 3100)).toBe(true);
+    expect(showAndAdvance('warning', 5999)).toBe(true);
+    expect(showAndAdvance('warning', 6000)).toBe(false);
+  });
+
+  it('leaves success and info toasts on the 3s default', () => {
+    expect(showAndAdvance('success', 2999)).toBe(true);
+    expect(showAndAdvance('success', 3000)).toBe(false);
+    expect(showAndAdvance('info', 2999)).toBe(true);
+    expect(showAndAdvance('info', 3000)).toBe(false);
+  });
+});

+ 94 - 24
frontend/src/__tests__/hooks/useWebSocket.test.ts

@@ -321,10 +321,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates archives on print_complete message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -363,10 +359,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates archives on archive_created message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -404,10 +396,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates archives on archive_updated message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -444,10 +432,6 @@ describe('useWebSocket hook', () => {
 
     it('invalidates inventory queries on inventory_changed message', async () => {
       vi.useFakeTimers();
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
@@ -479,10 +463,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('handles missing_spool_assignment message without error', async () => {
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       renderHook(() => useWebSocket(), {
@@ -511,10 +491,6 @@ describe('useWebSocket hook', () => {
     });
 
     it('handles spool_assignment_verified messages (success and failure) without error', async () => {
-      vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
-        cb(0);
-        return 0;
-      });
       const { useWebSocket } = await import('../../hooks/useWebSocket');
 
       renderHook(() => useWebSocket(), {
@@ -645,6 +621,100 @@ describe('useWebSocket hook', () => {
     });
   });
 
+  /**
+   * #2754 (reporter @mic4rd): live updates froze whenever the tab wasn't in
+   * front, and caught up all at once on switching back. The cache writes ran
+   * inside requestAnimationFrame, and a hidden tab gets no rendering
+   * opportunities — so the browser holds queued frame callbacks indefinitely
+   * rather than merely throttling them.
+   *
+   * The stub below is what makes these tests meaningful: it hands back a
+   * handle and never invokes the callback, which is what a real hidden tab
+   * does. `document.hidden` is set alongside it to name the scenario, but the
+   * production code doesn't branch on visibility — it simply no longer defers
+   * to a frame. Reintroduce a rAF wrapper on either path and these fail.
+   */
+  describe('hidden tab (#2754)', () => {
+    let rafSpy: ReturnType<typeof vi.fn>;
+
+    beforeEach(() => {
+      // The shared test client sets gcTime: 0, which collects a query the
+      // moment it has no observers — advancing timers past the 100ms
+      // coalescing window would drop the entry we just wrote before we could
+      // read it back. Nothing observes ['printerStatus', 1] here, so this
+      // block needs a client that keeps unobserved data.
+      queryClient = new QueryClient({
+        defaultOptions: { queries: { retry: false, gcTime: Infinity } },
+      });
+      Object.defineProperty(document, 'hidden', { configurable: true, value: true });
+      // Order matters: vi.useFakeTimers() fakes requestAnimationFrame as well
+      // (backing it with the mock clock, so advanceTimersByTime would run it
+      // and hide the very defect under test). Stub it afterwards so the
+      // never-firing version is the one the hook sees.
+      vi.useFakeTimers();
+      rafSpy = vi.fn(() => 1);
+      vi.stubGlobal('requestAnimationFrame', rafSpy);
+    });
+
+    afterEach(() => {
+      vi.useRealTimers();
+      Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+    });
+
+    it('applies printer status to the query cache', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      act(() => {
+        ws.simulateMessage({
+          type: 'printer_status',
+          printer_id: 1,
+          data: { state: 'RUNNING', progress: 42 },
+        });
+      });
+
+      // Past the 100ms coalescing window.
+      await act(async () => {
+        vi.advanceTimersByTime(200);
+      });
+
+      // This is the key the tab-title/favicon progress reads
+      // (usePrintProgressTitle) and nothing else.
+      expect(queryClient.getQueryData(['printerStatus', 1])).toMatchObject({
+        state: 'RUNNING',
+        progress: 42,
+      });
+      expect(rafSpy).not.toHaveBeenCalled();
+    });
+
+    it('drains queued messages instead of wedging the queue', async () => {
+      const { useWebSocket } = await import('../../hooks/useWebSocket');
+      const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
+
+      renderHook(() => useWebSocket(), { wrapper: createWrapper(queryClient) });
+      const ws = await waitForWs();
+      act(() => ws.open());
+
+      // Everything other than printer_status goes through the message queue,
+      // which used to stall with processingRef stuck true — messages then
+      // piled up unbounded until the tab was shown again.
+      act(() => {
+        ws.simulateMessage({ type: 'print_complete', printer_id: 1, data: {} });
+      });
+
+      // 3s debounce, then the 500ms-apart stagger.
+      await act(async () => {
+        vi.advanceTimersByTime(4000);
+      });
+
+      expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['archives'] });
+      expect(rafSpy).not.toHaveBeenCalled();
+    });
+  });
+
   describe('sendMessage', () => {
     it('sends JSON message when connected', async () => {
       const { useWebSocket } = await import('../../hooks/useWebSocket');

+ 262 - 0
frontend/src/__tests__/pages/PrintersPageCardScale.test.tsx

@@ -0,0 +1,262 @@
+/**
+ * Printer-card body scale (#1848, reporter @misterff1).
+ *
+ * S/M/L/XL already scaled the card's width, thumbnail and printer name, but
+ * every label in the body was pinned at 8-11px, so an XL card carried the same
+ * tiny text as an S one. The body now scales too, driven by custom properties
+ * on the card root.
+ *
+ * S and M stay at 1.0 on purpose: an existing install must look identical
+ * until the user reaches for a size that is already asking for more space.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 3,
+};
+
+const STATUS = {
+  connected: true,
+  state: 'IDLE',
+  progress: 0,
+  layer_num: 0,
+  total_layers: 0,
+  temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+  remaining_time: 0,
+  filename: null,
+  wifi_signal: -29,
+  speed_level: 2,
+  ams: [
+    {
+      id: 0,
+      humidity: 30,
+      temp: 28.2,
+      is_ams_ht: false,
+      serial_number: 'AMS00',
+      sw_ver: '03.00.21.29',
+      dry_time: 0,
+      dry_status: 0,
+      dry_sub_status: 0,
+      dry_sf_reason: [],
+      module_type: 'n3f',
+      tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
+    },
+    {
+      // AMS-HT: a single tray, with its temperature and humidity readings
+      // beside the slot rather than under it.
+      id: 128,
+      humidity: 52,
+      temp: 28.6,
+      is_ams_ht: true,
+      serial_number: 'HT00',
+      sw_ver: '03.00.21.29',
+      dry_time: 0,
+      dry_status: 0,
+      dry_sub_status: 0,
+      dry_sf_reason: [],
+      module_type: 'n3s',
+      tray: [{ id: 0, ...baseTray }],
+    },
+  ],
+  vt_tray: [],
+};
+
+let store: Record<string, string>;
+
+/** Render at a given card size and hand back the card root's inline style. */
+async function cardStyleAt(cardSize: string) {
+  store['printerCardSize'] = cardSize;
+  render(<PrintersPage />);
+  const card = await waitFor(() => {
+    const el = document.getElementById('printer-card-1');
+    if (!el) throw new Error('card not rendered');
+    return el as HTMLElement;
+  });
+  return card.style;
+}
+
+/** The AMS slot grid's track sizing, once the status has populated the card. */
+async function slotGridColumns(): Promise<string> {
+  return waitFor(() => {
+    const grid = document.querySelector<HTMLElement>('#printer-card-1 [style*="minmax"]');
+    if (!grid) throw new Error('AMS slot grid not rendered');
+    return grid.style.gridTemplateColumns;
+  });
+}
+
+/** The AMS-HT card's own sizing — it is the unit whose readings sit beside the slot. */
+async function htCardStyle(): Promise<CSSStyleDeclaration> {
+  return waitFor(() => {
+    const el = [...document.querySelectorAll<HTMLElement>('#printer-card-1 [class*="rounded-[10px]"]')]
+      .find((d) => /^HT-/.test((d.textContent || '').trim()));
+    if (!el) throw new Error('AMS-HT card not rendered');
+    return el.style;
+  });
+}
+
+/**
+ * The single filament slot inside the AMS-HT card. Scoped through the card
+ * itself, since the card carries a max-width of its own.
+ */
+async function htSlotStyle(): Promise<CSSStyleDeclaration> {
+  return waitFor(() => {
+    const card = [...document.querySelectorAll<HTMLElement>('#printer-card-1 [class*="rounded-[10px]"]')]
+      .find((d) => /^HT-/.test((d.textContent || '').trim()));
+    const el = card?.querySelector<HTMLElement>('[style*="max-width"]');
+    if (!el) throw new Error('AMS-HT slot not rendered');
+    return el.style;
+  });
+}
+
+describe('PrintersPage — printer card body scale (#1848)', () => {
+  beforeEach(() => {
+    store = {};
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store[key] = String(value);
+    });
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/printers/:id/status', () => HttpResponse.json(STATUS)),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+  });
+
+  it('leaves M — the default — at the sizes shipped before this change', async () => {
+    const style = await cardStyleAt('2');
+
+    expect(style.getPropertyValue('--pc-t10')).toBe('10px');
+    expect(style.getPropertyValue('--pc-t8')).toBe('8px');
+    expect(style.getPropertyValue('--pc-i3')).toBe('12px');
+    expect(style.getPropertyValue('--pc-i4')).toBe('16px');
+  });
+
+  it('leaves S at the same sizes — the dense fleet view wants density', async () => {
+    const style = await cardStyleAt('1');
+
+    expect(style.getPropertyValue('--pc-t10')).toBe('10px');
+    expect(style.getPropertyValue('--pc-i3')).toBe('12px');
+  });
+
+  it('scales the body type and icons at L', async () => {
+    const style = await cardStyleAt('3');
+
+    expect(style.getPropertyValue('--pc-t10')).toBe('12px');
+    expect(style.getPropertyValue('--pc-t8')).toBe('9.6px');
+    expect(style.getPropertyValue('--pc-t11')).toBe('13.2px');
+    expect(style.getPropertyValue('--pc-i3')).toBe('14.4px');
+    expect(style.getPropertyValue('--pc-i4')).toBe('19.2px');
+  });
+
+  it('scales further at XL, where the card is full width', async () => {
+    const style = await cardStyleAt('4');
+
+    expect(style.getPropertyValue('--pc-t10')).toBe('14px');
+    expect(style.getPropertyValue('--pc-i4')).toBe('22.4px');
+    // Every property is set at every size, so a converted class can never
+    // fall through to its fallback while sitting inside a card.
+    for (const name of ['--pc-t8', '--pc-t9', '--pc-t10', '--pc-t11',
+      '--pc-i2', '--pc-i25', '--pc-i3', '--pc-i35', '--pc-i4', '--pc-i5']) {
+      expect(style.getPropertyValue(name)).not.toBe('');
+    }
+  });
+
+  // Scaling these along with the type was tried and reverted. The AMS cards
+  // already grow to fill their row, so 3.5rem is a floor they sit well above;
+  // raising it only costs a unit its place on the row, and a wrapped AMS-HT is
+  // then alone on its line where flex-grow stretches its single slot across the
+  // whole card.
+  it('leaves the AMS slot columns at their fixed floor, at every size', async () => {
+    await cardStyleAt('2');
+    expect(await slotGridColumns()).toContain('3.5rem');
+  });
+
+  it('still leaves them alone at XL, where the type is largest', async () => {
+    await cardStyleAt('4');
+    expect(await slotGridColumns()).toContain('3.5rem');
+    expect(await slotGridColumns()).not.toContain('4.9rem');
+  });
+
+  // The AMS-HT does need its width scaled: its readings sit beside the slot,
+  // so bigger type eats the room they occupy.
+  it('widens the AMS-HT card with the type, and caps how wide it can get', async () => {
+    await cardStyleAt('3');
+    const ht = await htCardStyle();
+
+    expect(ht.minWidth).toBe('13.2rem'); // 11rem * 1.2
+    expect(ht.flex).toContain('13.2rem');
+    // Without a ceiling, an AMS-HT that wraps onto a line of its own is the
+    // only flex item there and grow stretches its single slot across the
+    // entire card, stranding the readings at the far edge.
+    expect(ht.maxWidth).toBe('calc(4 * 3.5rem + 3 * 0.25rem + 1rem)');
+  });
+
+  it('leaves the AMS-HT card as it was at M', async () => {
+    await cardStyleAt('2');
+    expect((await htCardStyle()).minWidth).toBe('11rem');
+  });
+
+  // The AMS-HT's single slot is the only growable item on its row, so it took
+  // every spare pixel and pushed the readings beside it hard against the card
+  // edge. Capping it is what keeps them clear.
+  it('caps the AMS-HT slot so it cannot swallow the row', async () => {
+    await cardStyleAt('2');
+    expect((await htSlotStyle()).maxWidth).toBe('7.25rem');
+  });
+
+  it('scales that cap with the type, since the slot label scales too', async () => {
+    await cardStyleAt('4');
+    expect((await htSlotStyle()).maxWidth).toBe('10.15rem'); // 7.25rem * 1.4
+  });
+
+  it('drives real elements, not just the root variables', async () => {
+    await cardStyleAt('3');
+
+    // The printer name already scaled before this change and still does.
+    const heading = await screen.findByRole('heading', { name: 'X1C' });
+    expect(heading.className).toContain('text-xl');
+
+    // Body labels now reference the scaled property rather than a fixed px.
+    const scaled = document.querySelectorAll('#printer-card-1 [class*="--pc-t"]');
+    expect(scaled.length).toBeGreaterThan(0);
+  });
+});

+ 181 - 0
frontend/src/__tests__/pages/PrintersPageExternalSpoolToggle.test.tsx

@@ -0,0 +1,181 @@
+/**
+ * Hiding the external spool from the printer card (#1782, reporter @Arn0uDz).
+ *
+ * The toggle lives in the filament section header next to the AMS Backup
+ * badge. It is offered only when an AMS is present: on a printer that feeds
+ * from the external spool alone, the external spool IS the filament section,
+ * so hiding it would leave an empty row and no way to see the loaded filament.
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const STORE_KEY = 'printerHiddenExternalSpools';
+
+const mockPrinter = {
+  id: 1,
+  name: 'X1C',
+  ip_address: '192.168.1.100',
+  serial_number: '01P00A000000001',
+  access_code: '12345678',
+  model: 'X1C',
+  enabled: true,
+  nozzle_diameter: 0.4,
+  nozzle_type: 'stainless_steel',
+  location: 'Workshop',
+  auto_archive: true,
+  created_at: '2024-01-01T00:00:00Z',
+  updated_at: '2024-01-01T00:00:00Z',
+};
+
+const baseTray = {
+  tray_color: 'FF0000FF',
+  tray_type: 'PLA',
+  tray_sub_brands: 'PLA Basic',
+  tray_id_name: 'A00-R0',
+  tray_info_idx: 'GFA00',
+  remain: 80,
+  k: 0.02,
+  cali_idx: null,
+  tag_uid: null,
+  tray_uuid: null,
+  nozzle_temp_min: 190,
+  nozzle_temp_max: 230,
+  drying_temp: null,
+  drying_time: null,
+  state: 3,
+};
+
+const amsUnit = {
+  id: 0,
+  humidity: 30,
+  temp: 33,
+  is_ams_ht: false,
+  serial_number: 'AMS00',
+  sw_ver: '03.00.21.29',
+  dry_time: 0,
+  dry_status: 0,
+  dry_sub_status: 0,
+  dry_sf_reason: [],
+  module_type: 'n3f',
+  tray: [0, 1, 2, 3].map((id) => ({ id, ...baseTray })),
+};
+
+function makeStatus({ withAms }: { withAms: boolean }) {
+  return {
+    connected: true,
+    state: 'IDLE',
+    progress: 0,
+    layer_num: 0,
+    total_layers: 0,
+    temperatures: { nozzle: 25, bed: 25, chamber: 25 },
+    remaining_time: 0,
+    filename: null,
+    wifi_signal: -29,
+    speed_level: 2,
+    supports_drying: true,
+    drying_screen_only: false,
+    ams: withAms ? [amsUnit] : [],
+    vt_tray: [{ id: 254, ...baseTray, tray_type: 'PETG', tray_sub_brands: 'PETG HF' }],
+  };
+}
+
+const WITH_AMS = makeStatus({ withAms: true });
+const WITHOUT_AMS = makeStatus({ withAms: false });
+
+const HIDE_TITLE = 'Hide external spool';
+const SHOW_TITLE = 'Show external spool';
+
+/** The external spool's own card is labelled with `printers.external`. */
+function externalSpoolCards() {
+  return screen.queryAllByText('External');
+}
+
+let store: Record<string, string>;
+
+describe('PrintersPage — hide the external spool (#1782)', () => {
+  beforeEach(() => {
+    store = {};
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store[key] = String(value);
+    });
+    server.use(
+      http.get('/api/v1/printers/', () => HttpResponse.json([mockPrinter])),
+      http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    );
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+  });
+
+  it('hides the external spool when the toggle is clicked, and brings it back', async () => {
+    const user = userEvent.setup();
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
+
+    render(<PrintersPage />);
+
+    const toggle = await screen.findByTitle(HIDE_TITLE);
+    expect(externalSpoolCards().length).toBeGreaterThan(0);
+
+    await user.click(toggle);
+    await waitFor(() => expect(externalSpoolCards()).toHaveLength(0));
+
+    // The toggle itself stays put — it is the only way back.
+    const restore = await screen.findByTitle(SHOW_TITLE);
+    await user.click(restore);
+    await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
+  });
+
+  it('persists the choice per printer', async () => {
+    const user = userEvent.setup();
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
+
+    render(<PrintersPage />);
+    await user.click(await screen.findByTitle(HIDE_TITLE));
+
+    // Keyed by printer id, so a second printer's card is untouched.
+    await waitFor(() => {
+      expect(JSON.parse(store[STORE_KEY])).toEqual({ '1': true });
+    });
+  });
+
+  it('starts hidden when the stored preference says so', async () => {
+    store[STORE_KEY] = JSON.stringify({ '1': true });
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITH_AMS)));
+
+    render(<PrintersPage />);
+
+    await screen.findByTitle(SHOW_TITLE);
+    expect(externalSpoolCards()).toHaveLength(0);
+  });
+
+  it('does not offer the toggle on a printer with no AMS', async () => {
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITHOUT_AMS)));
+
+    render(<PrintersPage />);
+
+    // The external spool is the whole filament section here, so it must stay.
+    await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
+    expect(screen.queryByTitle(HIDE_TITLE)).not.toBeInTheDocument();
+    expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument();
+  });
+
+  it('ignores a stored preference once the printer has no AMS left', async () => {
+    // The AMS was unplugged after the user hid the external spool. Honouring
+    // the stored flag would blank the filament row with no control to undo it.
+    store[STORE_KEY] = JSON.stringify({ '1': true });
+    server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(WITHOUT_AMS)));
+
+    render(<PrintersPage />);
+
+    await waitFor(() => expect(externalSpoolCards().length).toBeGreaterThan(0));
+    expect(screen.queryByTitle(SHOW_TITLE)).not.toBeInTheDocument();
+  });
+});

+ 145 - 0
frontend/src/__tests__/pages/StreamOverlayPage.test.tsx

@@ -430,4 +430,149 @@ describe('StreamOverlayPage', () => {
       expect(WebSocket).not.toHaveBeenCalled();
     });
   });
+
+  describe('temperatures (#1422)', () => {
+    const withTemps = {
+      ...mockStatusPrinting,
+      temperatures: {
+        nozzle: 219.6,
+        nozzle_target: 220,
+        bed: 60,
+        bed_target: 60,
+        chamber: 38.4,
+      },
+    };
+
+    beforeEach(() => {
+      server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(withTemps)));
+    });
+
+    it('draws no temperatures unless the URL asks for them', async () => {
+      renderOverlayPage(1);
+
+      await waitFor(() => {
+        expect(screen.getByText('45%')).toBeInTheDocument();
+      });
+      // Default ?show= is unchanged by #1422, so overlays already running in an
+      // OBS scene look identical after the upgrade.
+      expect(screen.queryByText('Nozzle')).not.toBeInTheDocument();
+      expect(screen.queryByText('Bed')).not.toBeInTheDocument();
+    });
+
+    it('draws only the readings named in ?show=', async () => {
+      renderOverlayPage(1, '?show=progress,nozzle');
+
+      await waitFor(() => {
+        expect(screen.getByText('Nozzle')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Bed')).not.toBeInTheDocument();
+      expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
+    });
+
+    it('rounds the reading and hides a target it has already reached', async () => {
+      renderOverlayPage(1, '?show=nozzle,bed');
+
+      await waitFor(() => {
+        expect(screen.getByText('220°C')).toBeInTheDocument();
+      });
+      // Nozzle is 219.6 against a target of 220: both round to 220, so the
+      // "/ 220°C" half is dropped rather than reading "220 / 220°C" all print.
+      expect(screen.queryByText('/')).not.toBeInTheDocument();
+      expect(screen.getByText('60°C')).toBeInTheDocument();
+    });
+
+    it('shows the target while the heater is still climbing', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({ ...withTemps, temperatures: { nozzle: 140, nozzle_target: 220 } }),
+        ),
+      );
+      renderOverlayPage(1, '?show=nozzle');
+
+      await waitFor(() => {
+        expect(screen.getByText('140°C')).toBeInTheDocument();
+      });
+      expect(screen.getByText('220°C')).toBeInTheDocument();
+    });
+
+    it('skips a reading the printer does not report', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () =>
+          // A P1S: the backend drops chamber for models without a real sensor,
+          // so asking for it in ?show= must not produce an empty row.
+          HttpResponse.json({ ...withTemps, temperatures: { nozzle: 200, bed: 55 } }),
+        ),
+      );
+      renderOverlayPage(1, '?show=nozzle,bed,chamber');
+
+      await waitFor(() => {
+        expect(screen.getByText('Nozzle')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('Chamber')).not.toBeInTheDocument();
+    });
+
+    it('draws both nozzles on a dual-nozzle printer', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({
+            ...withTemps,
+            temperatures: { nozzle: 220, nozzle_2: 250, nozzle_2_target: 250 },
+          }),
+        ),
+      );
+      renderOverlayPage(1, '?show=nozzle');
+
+      await waitFor(() => {
+        expect(screen.getByText('Nozzle')).toBeInTheDocument();
+      });
+      expect(screen.getByText('Nozzle 2')).toBeInTheDocument();
+      expect(screen.getByText('250°C')).toBeInTheDocument();
+    });
+
+    it('draws temperatures while the printer is idle', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/status', () =>
+          HttpResponse.json({ ...mockStatusIdle, temperatures: { bed: 45, bed_target: 60 } }),
+        ),
+      );
+      renderOverlayPage(1, '?show=bed');
+
+      await waitFor(() => {
+        expect(screen.getByText('Printer is idle')).toBeInTheDocument();
+      });
+      // A preheating printer is exactly when the readings are worth watching,
+      // so they are not gated behind a running print.
+      expect(screen.getByText('45°C')).toBeInTheDocument();
+      expect(screen.getByText('60°C')).toBeInTheDocument();
+    });
+
+    it('reads temperatures from the token-authed feed in kiosk mode', async () => {
+      server.use(
+        http.get('/api/v1/printers/:id/overlay-status', () =>
+          HttpResponse.json({
+            id: 1,
+            name: 'X1 Carbon',
+            camera_rotation: 0,
+            connected: true,
+            state: 'RUNNING',
+            current_print: 'KioskBenchy.gcode.3mf',
+            gcode_file: 'plate_1.gcode',
+            progress: 67,
+            remaining_time: 40,
+            layer_num: 10,
+            total_layers: 20,
+            stg_cur_name: null,
+            temperatures: { chamber: 38, chamber_target: 40 },
+            time_format: 'system',
+          }),
+        ),
+      );
+      renderOverlayPage(1, '?token=obs-tok&show=chamber');
+
+      await waitFor(() => {
+        expect(screen.getByText('Chamber')).toBeInTheDocument();
+      });
+      expect(screen.getByText('38°C')).toBeInTheDocument();
+    });
+  });
 });

+ 101 - 0
frontend/src/__tests__/utils/printerCardPrefs.test.ts

@@ -0,0 +1,101 @@
+/**
+ * Per-printer printer-card view preferences (#1782).
+ *
+ * The store is keyed by printer id so the toggle on one card cannot rearrange
+ * another, and it has to survive whatever is already sitting in localStorage —
+ * a value from an older format, or one another tab mangled — without throwing
+ * out of a render.
+ *
+ * The shared test setup stubs localStorage with bare vi.fn()s that store
+ * nothing, so this file backs them with a real in-memory object; a round-trip
+ * is the whole point of what's under test here.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+  isExternalSpoolHidden,
+  setExternalSpoolHidden,
+} from '../../utils/printerCardPrefs';
+
+const KEY = 'printerHiddenExternalSpools';
+
+let store: Record<string, string>;
+
+function stored(): unknown {
+  return JSON.parse(store[KEY]);
+}
+
+describe('printerCardPrefs — external spool visibility', () => {
+  beforeEach(() => {
+    store = {};
+    vi.mocked(localStorage.getItem).mockImplementation((key: string) => store[key] ?? null);
+    vi.mocked(localStorage.setItem).mockImplementation((key: string, value: string) => {
+      store[key] = String(value);
+    });
+  });
+
+  afterEach(() => {
+    vi.mocked(localStorage.getItem).mockReset();
+    vi.mocked(localStorage.setItem).mockReset();
+  });
+
+  it('defaults to visible for a printer that was never toggled', () => {
+    expect(isExternalSpoolHidden(1)).toBe(false);
+  });
+
+  it('round-trips the hidden flag through localStorage', () => {
+    setExternalSpoolHidden(7, true);
+    expect(isExternalSpoolHidden(7)).toBe(true);
+    expect(stored()).toEqual({ '7': true });
+  });
+
+  it('keeps each printer independent', () => {
+    setExternalSpoolHidden(1, true);
+    expect(isExternalSpoolHidden(1)).toBe(true);
+    expect(isExternalSpoolHidden(2)).toBe(false);
+
+    // Hiding a second printer must not disturb the first — the writer
+    // re-reads before merging rather than overwriting the whole object.
+    setExternalSpoolHidden(2, true);
+    expect(isExternalSpoolHidden(1)).toBe(true);
+    expect(isExternalSpoolHidden(2)).toBe(true);
+  });
+
+  it('drops the key when shown again rather than storing false', () => {
+    setExternalSpoolHidden(3, true);
+    setExternalSpoolHidden(3, false);
+
+    expect(isExternalSpoolHidden(3)).toBe(false);
+    // Otherwise the object grows an entry for every printer ever toggled twice.
+    expect(stored()).toEqual({});
+  });
+
+  it('treats malformed stored values as "nothing hidden"', () => {
+    for (const junk of ['not json', 'null', '"a string"', '[1,2,3]', '42']) {
+      store[KEY] = junk;
+      expect(isExternalSpoolHidden(1)).toBe(false);
+    }
+  });
+
+  it('recovers from a malformed store on the next write', () => {
+    store[KEY] = '[1,2,3]';
+    setExternalSpoolHidden(5, true);
+
+    expect(isExternalSpoolHidden(5)).toBe(true);
+    expect(stored()).toEqual({ '5': true });
+  });
+
+  it('survives localStorage being unavailable', () => {
+    vi.mocked(localStorage.getItem).mockImplementation(() => {
+      throw new Error('SecurityError: access denied');
+    });
+    vi.mocked(localStorage.setItem).mockImplementation(() => {
+      throw new Error('QuotaExceededError');
+    });
+
+    // Private-mode browsers throw on both. Neither may escape into a render.
+    expect(() => isExternalSpoolHidden(1)).not.toThrow();
+    expect(isExternalSpoolHidden(1)).toBe(false);
+    expect(() => setExternalSpoolHidden(1, true)).not.toThrow();
+  });
+});

+ 77 - 0
frontend/src/api/client.ts

@@ -341,6 +341,10 @@ export interface OverlayStatus {
   layer_num: number | null;
   total_layers: number | null;
   stg_cur_name: string | null;
+  // Nozzle / bed / chamber readings for the overlay's temperature fields
+  // (#1422). Only the keys a viewer is shown; chamber is absent on models
+  // without a real sensor.
+  temperatures: Record<string, number>;
   time_format: 'system' | '12h' | '24h';
 }
 
@@ -2286,6 +2290,32 @@ export interface PrintQueueItem {
   cleanup_library_after_dispatch?: boolean;
 }
 
+export interface PrintBatchPlateTarget {
+  plate_id: number | null;
+  plate_name?: string | null;
+  quantity_target: number;
+  sort_order?: number;
+}
+
+export interface PrintBatchPlateProgress {
+  plate_id: number | null;
+  plate_name: string | null;
+  quantity_target: number;
+  dispatched: number;
+  remaining: number;
+  pending_count: number;
+  printing_count: number;
+  completed_count: number;
+  failed_count: number;
+  cancelled_count: number;
+  skipped_count: number;
+  /** Measured from finished runs; null until one has produced a cost. */
+  actual_cost: number | null;
+  estimated_remaining_cost: number | null;
+  filament_used_grams: number | null;
+  print_time_seconds: number;
+}
+
 export interface PrintBatch {
   id: number;
   name: string;
@@ -2294,13 +2324,28 @@ export interface PrintBatch {
   quantity: number;
   status: string;
   created_at: string;
+  completed_at: string | null;
   created_by_id: number | null;
   created_by_username: string | null;
+  project_id: number | null;
+  due_date: string | null;
+  notes: string | null;
   pending_count: number;
   printing_count: number;
   completed_count: number;
   failed_count: number;
   cancelled_count: number;
+  skipped_count: number;
+  /** False for batches created before per-plate targets existed (#342):
+   *  they report progress but owe nothing and cannot be dispatched from. */
+  has_targets: boolean;
+  target_count: number;
+  remaining_count: number;
+  actual_cost: number | null;
+  estimated_remaining_cost: number | null;
+  filament_used_grams: number | null;
+  print_time_seconds: number;
+  plates: PrintBatchPlateProgress[];
 }
 
 export interface PrintQueueItemCreate {
@@ -2367,6 +2412,28 @@ export interface PrintBatchCreate {
    *  (manual "Group as batch"). When omitted/empty, an empty batch is
    *  returned so the client can pass batch_id on subsequent addToQueue calls. */
   item_ids?: number[];
+  /** Per-plate targets. Omitting them creates a plain grouping batch. */
+  plates?: PrintBatchPlateTarget[];
+  project_id?: number | null;
+  due_date?: string | null;
+  notes?: string | null;
+}
+
+export interface PrintBatchUpdate {
+  name?: string;
+  status?: 'active' | 'cancelled';
+  /** Replaces the full target set — a plate omitted here is removed. */
+  plates?: PrintBatchPlateTarget[];
+  project_id?: number | null;
+  due_date?: string | null;
+  notes?: string | null;
+}
+
+export interface PrintBatchDispatchRequest {
+  plate_id?: number | null;
+  only_plate?: boolean;
+  /** Cap on items created across all plates; omit to queue everything owed. */
+  limit?: number;
 }
 
 export interface PrintQueueItemUpdate {
@@ -5204,6 +5271,16 @@ export const api = {
       method: 'POST',
       body: JSON.stringify(data),
     }),
+  updateBatch: (id: number, data: PrintBatchUpdate) =>
+    request<PrintBatch>(`/queue/batches/${id}`, {
+      method: 'PATCH',
+      body: JSON.stringify(data),
+    }),
+  dispatchBatch: (id: number, data: PrintBatchDispatchRequest = {}) =>
+    request<PrintBatch>(`/queue/batches/${id}/dispatch`, {
+      method: 'POST',
+      body: JSON.stringify(data),
+    }),
   ungroupBatch: (id: number) =>
     request<{ ungrouped_count: number; message: string }>(
       `/queue/batches/${id}/ungroup`,

+ 345 - 0
frontend/src/components/BatchOrdersView.tsx

@@ -0,0 +1,345 @@
+import { useState } from 'react';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Package, Layers, PlayCircle, XCircle, AlertTriangle, Clock, Coins } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrintBatch, PrintBatchPlateProgress, Permission } from '../api/client';
+import { Card } from './Card';
+import { Button } from './Button';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { formatDuration, parseUTCDate } from '../utils/date';
+import { getCurrencySymbol } from '../utils/currency';
+
+type StatusFilter = 'active' | 'completed' | 'cancelled' | 'all';
+
+interface BatchOrdersViewProps {
+  hasPermission: (p: Permission) => boolean;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}
+
+/**
+ * Batch orders tab (#342).
+ *
+ * An order lives longer than the queue it spawned: once its runs finish they
+ * leave the active queue entirely, so the Queue and History tabs each hold
+ * only half the picture. This is the one place that shows what was asked for
+ * against what has actually been produced — including the runs that failed and
+ * are therefore still owed.
+ */
+export function BatchOrdersView({ hasPermission, t }: BatchOrdersViewProps) {
+  const queryClient = useQueryClient();
+  const { showToast } = useToast();
+  const [statusFilter, setStatusFilter] = useState<StatusFilter>('active');
+  const [cancelTarget, setCancelTarget] = useState<PrintBatch | null>(null);
+
+  const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: api.getSettings });
+  const currency = getCurrencySymbol(settings?.currency || 'USD');
+
+  const { data: batches, isLoading } = useQuery({
+    queryKey: ['batches', statusFilter],
+    queryFn: () => api.getBatches(statusFilter === 'all' ? undefined : statusFilter),
+  });
+
+  const invalidate = () => {
+    queryClient.invalidateQueries({ queryKey: ['batches'] });
+    queryClient.invalidateQueries({ queryKey: ['queue'] });
+  };
+
+  const dispatchMutation = useMutation({
+    mutationFn: ({ id, plateId }: { id: number; plateId?: number | null }) =>
+      api.dispatchBatch(id, plateId !== undefined ? { plate_id: plateId, only_plate: true } : {}),
+    onSuccess: (batch) => {
+      invalidate();
+      showToast(t('queue.batchOrders.dispatched', { name: batch.name }), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const cancelMutation = useMutation({
+    mutationFn: (id: number) => api.cancelBatch(id),
+    onSuccess: () => {
+      invalidate();
+      setCancelTarget(null);
+      showToast(t('queue.batchCancelled'), 'success');
+    },
+    onError: (error: Error) => showToast(error.message, 'error'),
+  });
+
+  const canDispatch = hasPermission('queue:create' as Permission);
+  const canCancel = hasPermission('queue:delete_all' as Permission);
+
+  const filters: StatusFilter[] = ['active', 'completed', 'cancelled', 'all'];
+
+  return (
+    <div>
+      <div className="flex flex-wrap items-center gap-2 mb-4">
+        <Package className="w-5 h-5 text-cyan-700 dark:text-cyan-300" />
+        <h2 className="text-base sm:text-lg font-semibold text-white">{t('queue.batchOrders.title')}</h2>
+        <div className="flex gap-1 ml-auto">
+          {filters.map((value) => (
+            <button
+              key={value}
+              type="button"
+              onClick={() => setStatusFilter(value)}
+              className={`text-xs px-2.5 py-1 rounded-full border transition-colors ${
+                statusFilter === value
+                  ? 'border-bambu-green bg-bambu-green/10 text-bambu-green'
+                  : 'border-bambu-dark-tertiary text-bambu-gray hover:border-bambu-gray'
+              }`}
+            >
+              {t(`queue.batchOrders.filter.${value}`)}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      {isLoading ? (
+        <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
+      ) : !batches?.length ? (
+        <Card className="p-12 text-center border-dashed">
+          <Package className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
+          <h3 className="text-xl font-medium text-white mb-2">{t('queue.batchOrders.emptyTitle')}</h3>
+          <p className="text-bambu-gray max-w-md mx-auto">{t('queue.batchOrders.emptyDescription')}</p>
+        </Card>
+      ) : (
+        <div className="space-y-4">
+          {batches.map((batch) => (
+            <BatchOrderCard
+              key={batch.id}
+              batch={batch}
+              currency={currency}
+              canDispatch={canDispatch}
+              canCancel={canCancel}
+              isDispatching={dispatchMutation.isPending && dispatchMutation.variables?.id === batch.id}
+              onDispatch={(plateId) => dispatchMutation.mutate({ id: batch.id, plateId })}
+              onCancel={() => setCancelTarget(batch)}
+              t={t}
+            />
+          ))}
+        </div>
+      )}
+
+      {cancelTarget && (
+        <ConfirmModal
+          title={t('queue.cancelBatchConfirmTitle')}
+          message={t('queue.cancelBatchConfirmMessage')}
+          confirmText={t('queue.cancelBatch')}
+          variant="warning"
+          onConfirm={() => cancelMutation.mutate(cancelTarget.id)}
+          onCancel={() => setCancelTarget(null)}
+        />
+      )}
+    </div>
+  );
+}
+
+const STATUS_STYLES: Record<string, string> = {
+  active: 'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300',
+  completed: 'bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300',
+  cancelled: 'bg-bambu-dark-tertiary text-bambu-gray',
+};
+
+function BatchOrderCard({
+  batch,
+  currency,
+  canDispatch,
+  canCancel,
+  isDispatching,
+  onDispatch,
+  onCancel,
+  t,
+}: {
+  batch: PrintBatch;
+  currency: string;
+  canDispatch: boolean;
+  canCancel: boolean;
+  isDispatching: boolean;
+  onDispatch: (plateId?: number | null) => void;
+  onCancel: () => void;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}) {
+  // Progress is measured against the target, not against what was queued —
+  // that is the whole difference between an order and a grouping.
+  const denominator = batch.has_targets ? batch.target_count : batch.completed_count + batch.pending_count
+    + batch.printing_count + batch.failed_count;
+  const percent = denominator > 0 ? Math.round((batch.completed_count / denominator) * 100) : 0;
+  const dueDate = batch.due_date ? parseUTCDate(batch.due_date) : null;
+  const isOverdue = dueDate != null && batch.status === 'active' && dueDate.getTime() < Date.now();
+
+  return (
+    <Card className="p-4">
+      <div className="flex flex-wrap items-start gap-3 mb-3">
+        <div className="min-w-0 flex-1">
+          <div className="flex items-center gap-2 flex-wrap">
+            <p className="text-white font-medium truncate">{batch.name}</p>
+            <span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_STYLES[batch.status] ?? STATUS_STYLES.cancelled}`}>
+              {t(`queue.batchOrders.status.${batch.status}`)}
+            </span>
+            {!batch.has_targets && (
+              <span
+                className="text-xs px-2 py-0.5 rounded-full bg-bambu-dark-tertiary text-bambu-gray"
+                title={t('queue.batchOrders.noTargetsHint')}
+              >
+                {t('queue.batchOrders.noTargets')}
+              </span>
+            )}
+          </div>
+          <p className="text-xs text-bambu-gray mt-1">
+            {batch.created_by_username
+              ? t('queue.addedBy', { name: batch.created_by_username })
+              : null}
+            {dueDate && (
+              <span className={isOverdue ? 'text-orange-600 dark:text-orange-400' : ''}>
+                {batch.created_by_username ? ' • ' : ''}
+                {t('queue.batchOrders.due', { date: dueDate.toLocaleDateString() })}
+              </span>
+            )}
+          </p>
+          {batch.notes && <p className="text-xs text-bambu-gray mt-1 whitespace-pre-wrap">{batch.notes}</p>}
+        </div>
+
+        <div className="flex items-center gap-2 flex-shrink-0">
+          {batch.has_targets && batch.remaining_count > 0 && batch.status !== 'cancelled' && canDispatch && (
+            <Button variant="primary" size="sm" onClick={() => onDispatch()} disabled={isDispatching}>
+              <PlayCircle className="w-4 h-4 mr-1" />
+              {t('queue.batchOrders.dispatchRemaining', { count: batch.remaining_count })}
+            </Button>
+          )}
+          {batch.status === 'active' && batch.pending_count > 0 && canCancel && (
+            <Button variant="ghost" size="sm" onClick={onCancel}>
+              <XCircle className="w-4 h-4 mr-1" />
+              {t('queue.cancelBatch')}
+            </Button>
+          )}
+        </div>
+      </div>
+
+      <div className="flex items-center gap-3 mb-2">
+        <div className="flex-1 h-2 bg-bambu-dark-tertiary rounded-full overflow-hidden">
+          <div
+            className={`h-full rounded-full transition-all ${
+              batch.status === 'completed' ? 'bg-bambu-green' : 'bg-blue-500'
+            }`}
+            style={{ width: `${Math.min(100, percent)}%` }}
+          />
+        </div>
+        <span className="text-xs text-bambu-gray whitespace-nowrap tabular-nums">
+          {t('queue.batchProgress', { completed: batch.completed_count, total: denominator })}
+        </span>
+      </div>
+
+      <div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-bambu-gray mb-1">
+        {batch.printing_count > 0 && <span>{t('queue.batchOrders.printing', { count: batch.printing_count })}</span>}
+        {batch.pending_count > 0 && <span>{t('queue.batch.pendingCount', { count: batch.pending_count })}</span>}
+        {batch.failed_count > 0 && (
+          <span className="text-orange-600 dark:text-orange-400 flex items-center gap-1">
+            <AlertTriangle className="w-3 h-3" />
+            {t('queue.batchOrders.failed', { count: batch.failed_count })}
+          </span>
+        )}
+        {batch.has_targets && batch.remaining_count > 0 && (
+          <span>{t('queue.batchOrders.remaining', { count: batch.remaining_count })}</span>
+        )}
+        {batch.print_time_seconds > 0 && (
+          <span className="flex items-center gap-1">
+            <Clock className="w-3 h-3" />
+            {formatDuration(batch.print_time_seconds)}
+          </span>
+        )}
+        {batch.actual_cost != null && (
+          <span className="flex items-center gap-1">
+            <Coins className="w-3 h-3" />
+            <span>
+              {t('queue.batchOrders.costSoFar', {
+                amount: `${currency} ${batch.actual_cost.toFixed(2)}`,
+              })}
+            </span>
+            {batch.estimated_remaining_cost != null && batch.estimated_remaining_cost > 0 && (
+              <span>
+                {t('queue.batchOrders.costRemaining', {
+                  amount: `${currency} ${batch.estimated_remaining_cost.toFixed(2)}`,
+                })}
+              </span>
+            )}
+          </span>
+        )}
+      </div>
+
+      {batch.has_targets && batch.plates.length > 0 && (
+        <div className="mt-3 border-t border-bambu-dark-tertiary pt-3 space-y-1.5">
+          {batch.plates.map((plate) => (
+            <PlateRow
+              key={`${plate.plate_id ?? 'file'}`}
+              plate={plate}
+              batchStatus={batch.status}
+              currency={currency}
+              canDispatch={canDispatch}
+              isDispatching={isDispatching}
+              onDispatch={() => onDispatch(plate.plate_id)}
+              t={t}
+            />
+          ))}
+        </div>
+      )}
+    </Card>
+  );
+}
+
+function PlateRow({
+  plate,
+  batchStatus,
+  currency,
+  canDispatch,
+  isDispatching,
+  onDispatch,
+  t,
+}: {
+  plate: PrintBatchPlateProgress;
+  batchStatus: string;
+  currency: string;
+  canDispatch: boolean;
+  isDispatching: boolean;
+  onDispatch: () => void;
+  t: (key: string, options?: Record<string, unknown>) => string;
+}) {
+  const label = plate.plate_name
+    || (plate.plate_id != null ? t('queue.plateNumber', { index: plate.plate_id }) : t('queue.batchOrders.wholeFile'));
+
+  return (
+    <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
+      <Layers className="w-3.5 h-3.5 text-bambu-gray flex-shrink-0" />
+      <span className="text-white min-w-0 truncate">{label}</span>
+      <span className="text-bambu-gray tabular-nums">
+        {t('queue.batchOrders.plateProgress', {
+          completed: plate.completed_count,
+          target: plate.quantity_target,
+        })}
+      </span>
+      {plate.failed_count > 0 && (
+        <span className="text-orange-600 dark:text-orange-400">
+          {t('queue.batchOrders.failed', { count: plate.failed_count })}
+        </span>
+      )}
+      {plate.actual_cost != null && (
+        <span className="text-bambu-gray tabular-nums">{`${currency} ${plate.actual_cost.toFixed(2)}`}</span>
+      )}
+      {plate.remaining > 0 && batchStatus !== 'cancelled' && (
+        <span className="ml-auto flex items-center gap-2">
+          <span className="text-bambu-gray">
+            {t('queue.batchOrders.remaining', { count: plate.remaining })}
+          </span>
+          {canDispatch && (
+            <button
+              type="button"
+              onClick={onDispatch}
+              disabled={isDispatching}
+              className="text-bambu-green hover:underline disabled:opacity-50"
+            >
+              {t('queue.batchOrders.dispatchPlate')}
+            </button>
+          )}
+        </span>
+      )}
+    </div>
+  );
+}

+ 41 - 4
frontend/src/components/PrintModal/PlateSelector.tsx

@@ -19,6 +19,8 @@ export function PlateSelector({
   onSelectAll,
   onDeselectAll,
   multiSelect,
+  quantities,
+  onQuantityChange,
 }: PlateSelectorProps) {
   const { t } = useTranslation();
 
@@ -28,6 +30,10 @@ export function PlateSelector({
   }
 
   const allSelected = selectedPlates.size === plates.length;
+  const showQuantities = !!quantities && !!onQuantityChange;
+  const totalRuns = showQuantities
+    ? plates.reduce((sum, p) => (selectedPlates.has(p.index) ? sum + (quantities[p.index] ?? 1) : sum), 0)
+    : 0;
 
   return (
     <div className="mb-4">
@@ -60,15 +66,22 @@ export function PlateSelector({
         {plates.map((plate) => {
           const isSelected = selectedPlates.has(plate.index);
           return (
-            <button
+            /* The quantity stepper can't live inside the selection button —
+               nesting an input in a button is invalid and every keystroke
+               would toggle the plate. The card is a div; the selectable
+               region stays a button beside the stepper. */
+            <div
               key={plate.index}
-              type="button"
-              onClick={() => onToggle(plate.index)}
-              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors text-left ${
+              className={`flex items-center gap-2 p-2 rounded-lg border transition-colors ${
                 isSelected
                   ? 'border-bambu-green bg-bambu-green/10'
                   : 'border-bambu-dark-tertiary bg-bambu-dark hover:border-bambu-gray'
               }`}
+            >
+            <button
+              type="button"
+              onClick={() => onToggle(plate.index)}
+              className="flex items-center gap-2 text-left min-w-0 flex-1"
             >
               {multiSelect && (
                 isSelected
@@ -114,9 +127,33 @@ export function PlateSelector({
                 <Check className="w-4 h-4 text-bambu-green flex-shrink-0" />
               )}
             </button>
+            {showQuantities && isSelected && (
+              <div className="flex items-center gap-1 flex-shrink-0">
+                <span className="text-xs text-bambu-gray" aria-hidden="true">×</span>
+                <input
+                  type="number"
+                  min={1}
+                  max={999}
+                  value={quantities[plate.index] ?? 1}
+                  aria-label={t('queue.plateQuantityLabel', {
+                    plate: plate.name || t('queue.plateNumber', { index: plate.index }),
+                  })}
+                  onChange={(e) =>
+                    onQuantityChange(plate.index, Math.max(1, Math.min(999, parseInt(e.target.value) || 1)))
+                  }
+                  className="w-14 px-1.5 py-1 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                />
+              </div>
+            )}
+            </div>
           );
         })}
       </div>
+      {showQuantities && totalRuns > selectedPlates.size && (
+        <p className="text-xs text-bambu-gray mt-2">
+          {t('queue.plateQuantityTotal', { count: totalRuns })}
+        </p>
+      )}
     </div>
   );
 }

+ 59 - 13
frontend/src/components/PrintModal/index.tsx

@@ -124,6 +124,11 @@ export function PrintModal({
   // Quantity — number of copies (creates a batch if > 1)
   const [quantity, setQuantity] = useState(1);
 
+  // Per-plate quantities for multi-plate files (#342). Keyed by plate index;
+  // a plate with no entry means one run. Only used in create mode on a
+  // multi-plate file, where it replaces the single global Quantity field.
+  const [plateQuantities, setPlateQuantities] = useState<Record<number, number>>({});
+
   const [printOptions, setPrintOptions] = useState<PrintOptions>(() => {
     if (mode === 'edit-queue-item' && queueItem) {
       return {
@@ -984,28 +989,45 @@ export function PrintModal({
       return;
     }
 
-    // Multi-plate auto-batch: when the user adds 2+ plates from one source in
-    // a single create submission, pre-create a PrintBatch and pass its
-    // id to each subsequent addToQueue call so the queue UI groups them as a
-    // collapsible batch. Only triggered for single-target submissions —
-    // multi-printer fan-out keeps the old per-item shape.
+    // Batch order (#342): a create submission that produces more than one run
+    // from one source is pre-created as a batch carrying per-plate targets,
+    // and its id is passed to each subsequent addToQueue call. The targets are
+    // what make the order able to say a failed run is still owed — without
+    // them the batch only knows what it happened to queue. Only for
+    // single-target submissions; multi-printer fan-out keeps the old per-item
+    // shape, where "how many" is answered by the printer count.
+    const plateTargets = platesToQueue.map((plate, index) => {
+      const plateIndex = plate ? plate.index : selectedPlate;
+      return {
+        plate_id: plateIndex,
+        plate_name: plate ? (plate.name || null) : null,
+        quantity_target: quantityForPlate(plateIndex),
+        sort_order: index,
+      };
+    });
+    const totalRuns = plateTargets.reduce((sum, target) => sum + target.quantity_target, 0);
     const shouldAutoBatch =
       mode === 'create'
-      && platesToQueue.length > 1
+      && (platesToQueue.length > 1 || totalRuns > 1)
       && (assignmentMode === 'model' || selectedPrinters.length === 1);
     let autoBatchId: number | null = null;
     if (shouldAutoBatch) {
       try {
         const baseName = (archiveName || '').replace(/\.gcode\.3mf$/i, '').replace(/\.3mf$/i, '');
-        const batchName = `${baseName || 'Batch'} · ${platesToQueue.length} plates`;
+        const batchName = platesToQueue.length > 1
+          ? `${baseName || 'Batch'} · ${platesToQueue.length} plates`
+          : `${baseName || 'Batch'} ×${totalRuns}`;
         const batch = await api.createBatch({
           name: batchName,
           archive_id: isLibraryFile ? undefined : archiveId,
           library_file_id: isLibraryFile ? libraryFileId : undefined,
+          plates: plateTargets,
         });
         autoBatchId = batch.id;
       } catch {
         // Non-fatal: fall back to ungrouped items so the queue still works.
+        // The server still creates a plain batch when quantity > 1, so the
+        // queue grouping survives even when the order layer doesn't.
         autoBatchId = null;
       }
     }
@@ -1087,8 +1109,9 @@ export function PrintModal({
           } else {
             // Add-to-queue mode with model-based assignment
             const queueData = getQueueData(null, plateId);
-            if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
-            applyAsapInsertion(queueData, null, effectiveQuantity);
+            const plateQuantity = quantityForPlate(plateId);
+            if (plateQuantity > 1) queueData.quantity = plateQuantity;
+            applyAsapInsertion(queueData, null, plateQuantity);
             await addToQueueMutation.mutateAsync(queueData);
           }
           results.success++;
@@ -1142,8 +1165,9 @@ export function PrintModal({
             } else {
               // New print mode, staggered print, or edit mode with additional entries
               const queueData = getQueueData(printerId, plateId);
-              if (effectiveQuantity > 1) queueData.quantity = effectiveQuantity;
-              applyAsapInsertion(queueData, printerId, effectiveQuantity);
+              const plateQuantity = quantityForPlate(plateId);
+              if (plateQuantity > 1) queueData.quantity = plateQuantity;
+              applyAsapInsertion(queueData, printerId, plateQuantity);
               // Apply stagger offset for groups after the first
               if (useStagger) {
                 const groupIndex = Math.floor(i / scheduleOptions.staggerGroupSize);
@@ -1250,6 +1274,21 @@ export function PrintModal({
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
 
+  // On a multi-plate file the per-plate steppers own the quantity and the
+  // global field is hidden (#342) — the reporter's case is "plate 1 once,
+  // plate 2 twice", which one shared number cannot express. Single-plate
+  // files, and edit mode, keep the single field exactly as before.
+  const usePerPlateQuantities = mode === 'create' && isMultiPlate && plates.length > 1;
+
+  /** Runs to queue for one plate. `null` = the single-plate / whole-file case. */
+  const quantityForPlate = (plateIndex: number | null): number => {
+    if (!usePerPlateQuantities || plateIndex == null) return effectiveQuantity;
+    // Multi-printer fan-out already means one copy per printer; multiplying by
+    // a per-plate count on top would silently produce plates × printers × n.
+    if (assignmentMode === 'printer' && selectedPrinters.length > 1) return 1;
+    return Math.max(1, plateQuantities[plateIndex] ?? 1);
+  };
+
   // Clear gcode_injection if the admin removes all snippets while the modal
   // is open — the checkbox itself hides via hasGcodeSnippets in
   // ScheduleOptions, but the boolean would otherwise stay true and ship to
@@ -1411,6 +1450,10 @@ export function PrintModal({
               onSelectAll={!isEditing ? () => setSelectedPlates(new Set(plates.map(p => p.index))) : undefined}
               onDeselectAll={!isEditing ? () => setSelectedPlates(new Set()) : undefined}
               multiSelect={!isEditing}
+              quantities={usePerPlateQuantities ? plateQuantities : undefined}
+              onQuantityChange={usePerPlateQuantities
+                ? (plateIndex, value) => setPlateQuantities(prev => ({ ...prev, [plateIndex]: value }))
+                : undefined}
             />
 
             {/* Cross-model alternatives (#671) replace the printer picker entirely:
@@ -1606,8 +1649,11 @@ export function PrintModal({
               />
             )}
 
-            {/* Quantity — create multiple copies (batch). Hidden for multi-printer selection. */}
-            {mode !== 'edit-queue-item' && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
+            {/* Quantity — create multiple copies (batch). Hidden for multi-printer
+                selection, and for multi-plate files where the per-plate steppers
+                in PlateSelector own the number instead (#342). */}
+            {mode !== 'edit-queue-item' && !usePerPlateQuantities
+              && (assignmentMode === 'model' || selectedPrinters.length <= 1) && (
               <div className="flex items-center gap-3">
                 <label htmlFor="printQuantity" className="text-sm text-bambu-gray whitespace-nowrap">
                   {t('queue.quantity', 'Quantity')}

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

@@ -197,6 +197,14 @@ export interface PlateSelectorProps {
   onDeselectAll?: () => void;
   /** Whether multi-select (checkboxes) is enabled */
   multiSelect?: boolean;
+  /**
+   * How many runs of each plate to queue, keyed by plate index (#342). When
+   * provided, each selected plate gets its own quantity control and the
+   * modal's single global Quantity field is hidden — one number per plate is
+   * the whole point, and two controls for the same value would be ambiguous.
+   */
+  quantities?: Record<number, number>;
+  onQuantityChange?: (plateIndex: number, quantity: number) => void;
 }
 
 /**

+ 301 - 0
frontend/src/components/StreamOverlayBuilder.tsx

@@ -0,0 +1,301 @@
+/**
+ * Streaming-overlay URL builder (#1422).
+ *
+ * The overlay at /overlay/{printerId} has been configurable by query string
+ * since #2613, but only for people who found the parameters in the wiki. The
+ * issue asked for the field set to be selectable "through the web UI"; this is
+ * that surface. It composes a URL, it does not persist anything — the URL *is*
+ * the configuration, which keeps a scene in OBS reproducible by copy-paste and
+ * means two displays can show different fields off one token.
+ */
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
+import { api, type Printer } from '../api/client';
+import { useToast } from '../contexts/ToastContext';
+
+type OverlaySize = 'small' | 'medium' | 'large';
+
+// Order matters: it is the order the fields appear in the overlay, so the
+// checkbox list reads as a preview of the result.
+const FIELDS = [
+  { key: 'printer', labelKey: 'streamOverlay.builder.fieldPrinter', fallback: 'Printer name' },
+  { key: 'filename', labelKey: 'streamOverlay.builder.fieldFilename', fallback: 'File name' },
+  { key: 'status', labelKey: 'streamOverlay.builder.fieldStatus', fallback: 'Status' },
+  { key: 'progress', labelKey: 'streamOverlay.builder.fieldProgress', fallback: 'Progress bar' },
+  { key: 'layers', labelKey: 'streamOverlay.builder.fieldLayers', fallback: 'Layer count' },
+  { key: 'eta', labelKey: 'streamOverlay.builder.fieldEta', fallback: 'Time remaining and ETA' },
+  { key: 'nozzle', labelKey: 'printers.heaterHistory.nozzle', fallback: 'Nozzle' },
+  { key: 'bed', labelKey: 'printers.heaterHistory.bed', fallback: 'Bed' },
+  { key: 'chamber', labelKey: 'printers.heaterHistory.chamber', fallback: 'Chamber' },
+] as const;
+
+// Matches parseConfig() in StreamOverlayPage: the fields an overlay shows when
+// the URL carries no ?show= at all.
+const DEFAULT_FIELDS = ['progress', 'layers', 'eta', 'filename', 'status'];
+
+const DEFAULT_FPS = 15;
+
+export function StreamOverlayBuilder() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+
+  const [printers, setPrinters] = useState<Printer[]>([]);
+  const [printerId, setPrinterId] = useState<number | null>(null);
+  const [fields, setFields] = useState<string[]>(DEFAULT_FIELDS);
+  const [size, setSize] = useState<OverlaySize>('medium');
+  const [fps, setFps] = useState(DEFAULT_FPS);
+  const [showCamera, setShowCamera] = useState(true);
+  const [token, setToken] = useState('');
+  const [preview, setPreview] = useState(false);
+
+  useEffect(() => {
+    let cancelled = false;
+    void (async () => {
+      try {
+        const list = await api.getPrinters();
+        if (cancelled) return;
+        setPrinters(list);
+        if (list.length > 0) setPrinterId(list[0].id);
+      } catch {
+        // A failed printer list only costs the picker its options — the builder
+        // still works if the user types a printer number into the URL by hand,
+        // so this is not worth a toast on a settings page they may just be
+        // scrolling past.
+      }
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  const url = useMemo(() => {
+    const id = printerId ?? 1;
+    const params = new URLSearchParams();
+    // Emit ?show= in the canonical field order rather than click order, so the
+    // same selection always produces the same URL.
+    const selected = FIELDS.filter((f) => fields.includes(f.key)).map((f) => f.key);
+    params.set('show', selected.join(','));
+    if (size !== 'medium') params.set('size', size);
+    if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
+    if (!showCamera) params.set('camera', 'false');
+    if (token.trim()) params.set('token', token.trim());
+    return `${window.location.origin}/overlay/${id}?${params.toString()}`;
+  }, [printerId, fields, size, fps, showCamera, token]);
+
+  const toggleField = (key: string) => {
+    setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
+  };
+
+  const copyUrl = async () => {
+    try {
+      // Same fallback as the token dialog: the clipboard API needs a secure
+      // context, and plenty of Bambuddy installs are plain HTTP on a LAN.
+      if (navigator.clipboard && window.isSecureContext) {
+        await navigator.clipboard.writeText(url);
+      } else {
+        const ta = document.createElement('textarea');
+        ta.value = url;
+        ta.style.position = 'fixed';
+        ta.style.opacity = '0';
+        document.body.appendChild(ta);
+        try {
+          ta.select();
+          document.execCommand('copy');
+        } finally {
+          document.body.removeChild(ta);
+        }
+      }
+      showToast(t('cameraTokens.toast.copied', 'Copied to clipboard'));
+    } catch {
+      showToast(t('cameraTokens.toast.copyFailed', 'Copy failed — select and copy manually'), 'error');
+    }
+  };
+
+  return (
+    <div>
+      <p className="text-sm text-bambu-gray mb-4">
+        {t(
+          'streamOverlay.builder.description',
+          'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.',
+        )}
+      </p>
+
+      <div className="grid gap-4 md:grid-cols-2">
+        <div>
+          <label
+            htmlFor="overlay-builder-printer"
+            className="block text-sm font-medium text-white mb-1"
+          >
+            {t('streamOverlay.builder.printer', 'Printer')}
+          </label>
+          <select
+            id="overlay-builder-printer"
+            value={printerId ?? ''}
+            onChange={(e) => setPrinterId(Number(e.target.value))}
+            className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
+          >
+            {printers.length === 0 && <option value="">{t('common.loading', 'Loading…')}</option>}
+            {printers.map((p) => (
+              <option key={p.id} value={p.id}>
+                {p.name}
+              </option>
+            ))}
+          </select>
+        </div>
+
+        <div>
+          <label htmlFor="overlay-builder-size" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.size', 'Text size')}
+          </label>
+          <select
+            id="overlay-builder-size"
+            value={size}
+            onChange={(e) => setSize(e.target.value as OverlaySize)}
+            className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
+          >
+            <option value="small">{t('streamOverlay.builder.sizeSmall', 'Small')}</option>
+            <option value="medium">{t('streamOverlay.builder.sizeMedium', 'Medium')}</option>
+            <option value="large">{t('streamOverlay.builder.sizeLarge', 'Large')}</option>
+          </select>
+        </div>
+
+        <div>
+          <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.fps', 'Frame rate')}
+          </label>
+          <input
+            id="overlay-builder-fps"
+            type="number"
+            min={1}
+            max={30}
+            value={fps}
+            onChange={(e) => setFps(Math.min(Math.max(Number(e.target.value) || 1, 1), 30))}
+            className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
+          />
+          <p className="text-xs text-bambu-gray mt-1">
+            {t(
+              'streamOverlay.builder.fpsHint',
+              'A1 and P1 cameras top out around 5 fps whatever you ask for.',
+            )}
+          </p>
+        </div>
+
+        <div>
+          <label htmlFor="overlay-builder-token" className="block text-sm font-medium text-white mb-1">
+            {t('streamOverlay.builder.token', 'Streaming Overlay token (optional)')}
+          </label>
+          <input
+            id="overlay-builder-token"
+            type="text"
+            value={token}
+            onChange={(e) => setToken(e.target.value)}
+            placeholder="bblt_…"
+            className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none font-mono text-xs"
+          />
+          <p className="text-xs text-bambu-gray mt-1">
+            {t(
+              'streamOverlay.builder.tokenHint',
+              'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.',
+            )}
+          </p>
+        </div>
+      </div>
+
+      <fieldset className="mt-4">
+        <legend className="text-sm font-medium text-white mb-2">
+          {t('streamOverlay.builder.fields', 'Fields to show')}
+        </legend>
+        <div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3">
+          {FIELDS.map((field) => (
+            <label key={field.key} className="flex items-center gap-2 text-sm text-bambu-gray">
+              <input
+                type="checkbox"
+                checked={fields.includes(field.key)}
+                onChange={() => toggleField(field.key)}
+                className="accent-bambu-green"
+              />
+              {t(field.labelKey, field.fallback)}
+            </label>
+          ))}
+          <label className="flex items-center gap-2 text-sm text-bambu-gray">
+            <input
+              type="checkbox"
+              checked={showCamera}
+              onChange={(e) => setShowCamera(e.target.checked)}
+              className="accent-bambu-green"
+            />
+            {t('streamOverlay.builder.fieldCamera', 'Camera feed')}
+          </label>
+        </div>
+        <p className="text-xs text-bambu-gray mt-2">
+          {t(
+            'streamOverlay.builder.chamberHint',
+            'Chamber temperature only appears on models with a real chamber sensor — P1 and A1 printers report a meaningless value, so it is left out there.',
+          )}
+        </p>
+      </fieldset>
+
+      <div className="mt-4">
+        <p className="text-sm font-medium text-white mb-1">
+          {t('streamOverlay.builder.urlTitle', 'Overlay URL')}
+        </p>
+        <div className="flex items-center gap-2">
+          <code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+            {url}
+          </code>
+          <button
+            type="button"
+            onClick={() => void copyUrl()}
+            className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
+          >
+            <Copy className="w-4 h-4" />
+            {t('cameraTokens.created.copy', 'Copy')}
+          </button>
+          <a
+            href={url}
+            target="_blank"
+            rel="noopener noreferrer"
+            className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
+          >
+            <ExternalLink className="w-4 h-4" />
+            {t('streamOverlay.builder.open', 'Open')}
+          </a>
+        </div>
+        {token.trim() && (
+          <p className="text-xs text-bambu-gray mt-2">
+            {t(
+              'streamOverlay.builder.tokenWarning',
+              'This URL contains a token — anyone who can read it can watch the stream and see the file name. Revoke the token to cut it off.',
+            )}
+          </p>
+        )}
+      </div>
+
+      {/* The preview opens a real camera stream, so it stays off until asked
+          for. Leaving one running behind a settings tab would hold a subscriber
+          on the printer's single camera connection for as long as the tab is
+          open. */}
+      <div className="mt-4">
+        <button
+          type="button"
+          onClick={() => setPreview((p) => !p)}
+          className="flex items-center gap-2 px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 text-sm"
+        >
+          {preview ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+          {preview
+            ? t('streamOverlay.builder.hidePreview', 'Hide preview')
+            : t('streamOverlay.builder.showPreview', 'Show preview')}
+        </button>
+        {preview && (
+          <iframe
+            key={url}
+            src={url}
+            title={t('streamOverlay.builder.previewTitle', 'Overlay preview')}
+            className="mt-3 w-full aspect-video rounded-md border border-bambu-dark-tertiary bg-black"
+          />
+        )}
+      </div>
+    </div>
+  );
+}

+ 12 - 2
frontend/src/contexts/ToastContext.tsx

@@ -92,6 +92,16 @@ const bgColors = {
 const DISPATCH_TOAST_ID = 'background-dispatch';
 const DISPATCH_TERMINAL_DISMISS_MS = 3500;
 
+// Auto-dismiss windows for the plain (non-persistent) toasts. Errors and
+// warnings get double the default because they carry far more text than a
+// success confirmation — a backend failure reason or a validation message
+// often runs to a couple of lines, and 3s isn't long enough to finish
+// reading one before it slides away. Success/info stay short: they confirm
+// something the user just did and are skimmed, not read.
+const TOAST_DISMISS_MS = 3000;
+const TOAST_DISMISS_LONG_MS = 2 * TOAST_DISMISS_MS;
+const LONG_LIVED_TOAST_TYPES: ReadonlySet<ToastType> = new Set(['error', 'warning']);
+
 interface DispatchEventDetail {
   type: string;
   queue_item_id: number;
@@ -156,12 +166,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
     const id = Math.random().toString(36).substr(2, 9);
     setToasts((prev) => [...prev, { id, message, type }]);
 
-    // Auto-dismiss after 3 seconds
+    // Auto-dismiss — longer for the types that carry more to read.
     const timeout = setTimeout(() => {
       if (!isMountedRef.current) return;
       setToasts((prev) => prev.filter((t) => t.id !== id));
       timeoutRefs.current.delete(id);
-    }, 3000);
+    }, LONG_LIVED_TOAST_TYPES.has(type) ? TOAST_DISMISS_LONG_MS : TOAST_DISMISS_MS);
     timeoutRefs.current.set(id, timeout);
   }, []);
 

+ 36 - 27
frontend/src/hooks/useWebSocket.ts

@@ -69,16 +69,16 @@ export function useWebSocket() {
     const processNext = () => {
       const message = messageQueueRef.current.shift();
       if (message) {
-        // Use requestAnimationFrame to yield to the browser
-        requestAnimationFrame(() => {
-          handleMessageRef.current(message);
-          // Small delay between messages to prevent overwhelming the browser
-          if (messageQueueRef.current.length > 0) {
-            setTimeout(processNext, 16); // ~60fps
-          } else {
-            processingRef.current = false;
-          }
-        });
+        handleMessageRef.current(message);
+        // Small delay between messages to prevent overwhelming the browser.
+        // This setTimeout is the yield; a requestAnimationFrame around the
+        // handler used to sit here too, which stalled the whole queue in a
+        // hidden tab (see the note on the rAF removal below).
+        if (messageQueueRef.current.length > 0) {
+          setTimeout(processNext, 16); // ~60fps
+        } else {
+          processingRef.current = false;
+        }
       } else {
         processingRef.current = false;
       }
@@ -194,7 +194,17 @@ export function useWebSocket() {
     wsRef.current = ws;
   }, [processMessageQueue]);
 
-  // Throttled printer status update - coalesces rapid updates per printer
+  // Throttled printer status update - coalesces rapid updates per printer.
+  //
+  // #2754: these cache writes used to happen inside a requestAnimationFrame.
+  // A hidden tab gets no rendering opportunities, so the browser *holds*
+  // queued frame callbacks rather than throttling them — every status update
+  // parked in a pending frame and nothing reached the query cache until the
+  // tab was shown again, at which point they all ran at once. That froze the
+  // tab-title progress (usePrintProgressTitle reads this key and nothing
+  // else) and stalled every other live view. The 100ms coalescing below is
+  // what prevented the original render cascade; the frame callback only ever
+  // deferred the write by a frame, so it is gone.
   const throttledPrinterStatusUpdate = useCallback((printerId: number, data: Record<string, unknown>) => {
     // Merge with any pending data for this printer
     const existing = pendingPrinterStatus.current.get(printerId) || {};
@@ -208,19 +218,17 @@ export function useWebSocket() {
         printerStatusTimeoutRef.current = null;
 
         // Apply all pending updates
-        requestAnimationFrame(() => {
-          updates.forEach((statusData, id) => {
-            queryClient.setQueryData(
-              ['printerStatus', id],
-              (old: Record<string, unknown> | undefined) => {
-                const merged = { ...old, ...statusData };
-                if (merged.wifi_signal == null && old?.wifi_signal != null) {
-                  merged.wifi_signal = old.wifi_signal;
-                }
-                return merged;
+        updates.forEach((statusData, id) => {
+          queryClient.setQueryData(
+            ['printerStatus', id],
+            (old: Record<string, unknown> | undefined) => {
+              const merged = { ...old, ...statusData };
+              if (merged.wifi_signal == null && old?.wifi_signal != null) {
+                merged.wifi_signal = old.wifi_signal;
               }
-            );
-          });
+              return merged;
+            }
+          );
         });
       }, 100); // Update at most every 100ms
     }
@@ -241,13 +249,14 @@ export function useWebSocket() {
       pendingInvalidations.current.clear();
       invalidationTimeoutRef.current = null;
 
-      // Invalidate queries one at a time with delays to prevent freeze
+      // Invalidate queries one at a time with delays to prevent freeze.
+      // The 500ms stagger is the anti-cascade measure; a frame callback around
+      // each invalidation used to sit inside it and stalled these refreshes in
+      // a hidden tab for the same reason as the status writes above (#2754).
       let delay = 0;
       keys.forEach((key) => {
         setTimeout(() => {
-          requestAnimationFrame(() => {
-            queryClient.invalidateQueries({ queryKey: [key] });
-          });
+          queryClient.invalidateQueries({ queryKey: [key] });
         }, delay);
         delay += 500; // 500ms between each invalidation
       });

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamente',
+    externalSpool: {
+      hide: 'Externe Spule ausblenden',
+      show: 'Externe Spule einblenden',
+    },
     // Camera
     openCameraOverlay: 'Kamera-Overlay öffnen',
     openCameraWindow: 'Kamera in neuem Fenster öffnen',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: 'Menge',
     quantityHint: 'Erstellt {{count}} Warteschlangeneinträge',
+    plateQuantityLabel: 'Durchläufe von {{plate}}',
+    plateQuantityTotal_one: '{{count}} Durchlauf insgesamt',
+    plateQuantityTotal_other: '{{count}} Durchläufe insgesamt',
+    batchOrders: {
+      title: 'Stapelaufträge',
+      emptyTitle: 'Keine Stapelaufträge',
+      emptyDescription: 'Stelle eine Datei mit mehreren Druckplatten oder mehrere Kopien einer Platte in die Warteschlange — der Auftrag erscheint hier mit seinem Fortschritt.',
+      filter: {
+        active: 'Aktiv',
+        completed: 'Abgeschlossen',
+        cancelled: 'Abgebrochen',
+        all: 'Alle',
+      },
+      status: {
+        active: 'Aktiv',
+        completed: 'Abgeschlossen',
+        cancelled: 'Abgebrochen',
+      },
+      noTargets: 'Nur Gruppierung',
+      noTargetsHint: 'Vor der Einführung von Zielmengen pro Platte erstellt; verfolgt daher die eingereihten Durchläufe statt einer Zielmenge.',
+      dispatchRemaining: '{{count}} verbleibende einreihen',
+      dispatchPlate: 'Rest einreihen',
+      dispatched: 'Verbleibende Durchläufe für {{name}} eingereiht',
+      plateProgress: '{{completed}} von {{target}} fertig',
+      remaining: '{{count}} noch offen',
+      failed: '{{count}} fehlgeschlagen',
+      printing: '{{count}} im Druck',
+      wholeFile: 'Gesamte Datei',
+      due: 'Fällig {{date}}',
+      costSoFar: '{{amount}} bisher',
+      costRemaining: '({{amount}} verbleibend)',
+    },
     activeBatches: 'Aktive Stapel',
     batchProgress: '{{completed}} von {{total}} abgeschlossen',
     cancelBatch: 'Verbleibende abbrechen',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: 'Gruppe ziehen',
     },
     tabs: {
+      batches: 'Stapel',
       queue: 'Warteschlange',
       history: 'Verlauf',
       timeline: 'Zeitachse',
@@ -3296,6 +3333,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Drucker ist inaktiv',
     printerOffline: 'Drucker offline',
+    builder: {
+      title: 'Stream-Overlay',
+      description: 'Erstellt die URL für ein Stream-Overlay — eine bildschirmfüllende Kameraansicht mit eingeblendeten Live-Druckdaten, für OBS, ein Wanddisplay oder jede andere Browserquelle. Felder auswählen und URL kopieren.',
+      printer: 'Drucker',
+      size: 'Textgröße',
+      sizeSmall: 'Klein',
+      sizeMedium: 'Mittel',
+      sizeLarge: 'Groß',
+      fps: 'Bildrate',
+      fpsHint: 'A1- und P1-Kameras liefern höchstens etwa 5 Bilder pro Sekunde, unabhängig vom eingestellten Wert.',
+      token: 'Stream-Overlay-Token (optional)',
+      tokenHint: 'Nur nötig, wenn die Anmeldung aktiviert ist: OBS hat keine eigene Sitzung. Oben eines mit dem Bereich Stream-Overlay erstellen.',
+      tokenWarning: 'Diese URL enthält ein Token — wer sie lesen kann, sieht den Stream und den Dateinamen. Token widerrufen, um den Zugriff zu beenden.',
+      fields: 'Anzuzeigende Felder',
+      fieldPrinter: 'Druckername',
+      fieldFilename: 'Dateiname',
+      fieldStatus: 'Status',
+      fieldProgress: 'Fortschrittsbalken',
+      fieldLayers: 'Schichtanzahl',
+      fieldEta: 'Restzeit und ETA',
+      fieldCamera: 'Kamerabild',
+      chamberHint: 'Die Kammertemperatur erscheint nur bei Modellen mit echtem Kammersensor — P1- und A1-Drucker melden einen bedeutungslosen Wert und lassen sie deshalb weg.',
+      urlTitle: 'Overlay-URL',
+      open: 'Öffnen',
+      showPreview: 'Vorschau anzeigen',
+      hidePreview: 'Vorschau ausblenden',
+      previewTitle: 'Overlay-Vorschau',
+    },
     status: {
       printing: 'Druckt',
       paused: 'Pausiert',
@@ -6540,6 +6605,10 @@ export default {
         title: 'Erkennungsdienst (Port {{port}})',
         fail: 'Auf Port {{port}} der Bind-IP lauscht nichts, daher schlägt der Erkennungs-Handshake des Slicers fehl.',
       },
+      privileged_ports: {
+        title: 'Bindung an privilegierte Ports',
+        fail: 'Port {{port}} liegt unter 1024 und dieser Dienst darf ihn nicht belegen — deshalb lauscht oben nichts. Fügen Sie AmbientCapabilities=CAP_NET_BIND_SERVICE in /etc/systemd/system/bambuddy.service ein und starten Sie neu, oder führen Sie "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))" aus. Unter Docker ergänzen Sie cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: 'TLS-Zertifikat',
         pass: 'Zertifikat bereit. Stellen Sie sicher, dass das Bambuddy-CA-Zertifikat (oben) in den Vertrauensspeicher Ihres Slicers importiert ist.',

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

@@ -622,6 +622,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filaments',
+    externalSpool: {
+      hide: 'Hide external spool',
+      show: 'Show external spool',
+    },
     // Camera
     openCameraOverlay: 'Open camera overlay',
     openCameraWindow: 'Open camera in new window',
@@ -1207,6 +1211,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantity',
     quantityHint: 'Creates {{count}} queue items',
+    plateQuantityLabel: 'Runs of {{plate}}',
+    plateQuantityTotal_one: '{{count}} run in total',
+    plateQuantityTotal_other: '{{count}} runs in total',
+    batchOrders: {
+      title: 'Batch Orders',
+      emptyTitle: 'No batch orders',
+      emptyDescription: 'Queue a multi-plate file, or more than one copy of a plate, and the order shows up here with its progress.',
+      filter: {
+        active: 'Active',
+        completed: 'Completed',
+        cancelled: 'Cancelled',
+        all: 'All',
+      },
+      status: {
+        active: 'Active',
+        completed: 'Completed',
+        cancelled: 'Cancelled',
+      },
+      noTargets: 'Grouping only',
+      noTargetsHint: 'Created before per-plate targets existed, so it tracks the runs it queued rather than a target.',
+      dispatchRemaining: 'Queue {{count}} remaining',
+      dispatchPlate: 'Queue remaining',
+      dispatched: 'Queued the remaining runs for {{name}}',
+      plateProgress: '{{completed}} of {{target}} done',
+      remaining: '{{count}} still owed',
+      failed: '{{count}} failed',
+      printing: '{{count}} printing',
+      wholeFile: 'Whole file',
+      due: 'Due {{date}}',
+      costSoFar: '{{amount}} so far',
+      costRemaining: '({{amount}} to go)',
+    },
     activeBatches: 'Active Batches',
     batchProgress: '{{completed}} of {{total}} completed',
     cancelBatch: 'Cancel Remaining',
@@ -1233,6 +1269,7 @@ export default {
     },
     // Tabs
     tabs: {
+      batches: 'Batches',
       queue: 'Queue',
       history: 'History',
       timeline: 'Timeline',
@@ -3325,6 +3362,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Printer is idle',
     printerOffline: 'Printer offline',
+    builder: {
+      title: 'Streaming Overlay',
+      description: 'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.',
+      printer: 'Printer',
+      size: 'Text size',
+      sizeSmall: 'Small',
+      sizeMedium: 'Medium',
+      sizeLarge: 'Large',
+      fps: 'Frame rate',
+      fpsHint: 'A1 and P1 cameras top out around 5 fps whatever you ask for.',
+      token: 'Streaming Overlay token (optional)',
+      tokenHint: 'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.',
+      tokenWarning: 'This URL contains a token — anyone who can read it can watch the stream and see the file name. Revoke the token to cut it off.',
+      fields: 'Fields to show',
+      fieldPrinter: 'Printer name',
+      fieldFilename: 'File name',
+      fieldStatus: 'Status',
+      fieldProgress: 'Progress bar',
+      fieldLayers: 'Layer count',
+      fieldEta: 'Time remaining and ETA',
+      fieldCamera: 'Camera feed',
+      chamberHint: 'Chamber temperature only appears on models with a real chamber sensor — P1 and A1 printers report a meaningless value, so it is left out there.',
+      urlTitle: 'Overlay URL',
+      open: 'Open',
+      showPreview: 'Show preview',
+      hidePreview: 'Hide preview',
+      previewTitle: 'Overlay preview',
+    },
     status: {
       printing: 'Printing',
       paused: 'Paused',
@@ -6584,6 +6649,10 @@ export default {
         title: 'Discovery service (port {{port}})',
         fail: 'Nothing is listening on port {{port}} of the bind IP, so the slicer\'s discovery handshake fails.',
       },
+      privileged_ports: {
+        title: 'Privileged port binding',
+        fail: 'Port {{port}} is below 1024, and this service is not permitted to bind it — which is why nothing is listening above. Add AmbientCapabilities=CAP_NET_BIND_SERVICE to /etc/systemd/system/bambuddy.service and restart, or run "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". On Docker, add cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: 'TLS certificate',
         pass: 'Certificate ready. Make sure the Bambuddy CA certificate (above) is imported into your slicer\'s trust store.',

+ 69 - 0
frontend/src/i18n/locales/es.ts

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamentos',
+    externalSpool: {
+      hide: 'Ocultar bobina externa',
+      show: 'Mostrar bobina externa',
+    },
     // Camera
     openCameraOverlay: 'Abrir la cámara superpuesta',
     openCameraWindow: 'Abrir la cámara en una ventana nueva',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: 'Cantidad',
     quantityHint: 'Crea {{count}} elementos en la cola',
+    plateQuantityLabel: 'Impresiones de {{plate}}',
+    plateQuantityTotal_one: '{{count}} impresión en total',
+    plateQuantityTotal_other: '{{count}} impresiones en total',
+    batchOrders: {
+      title: 'Pedidos por lotes',
+      emptyTitle: 'Sin pedidos por lotes',
+      emptyDescription: 'Pon en cola un archivo de varias bandejas, o más de una copia de una bandeja, y el pedido aparecerá aquí con su progreso.',
+      filter: {
+        active: 'Activos',
+        completed: 'Completados',
+        cancelled: 'Cancelados',
+        all: 'Todos',
+      },
+      status: {
+        active: 'Activo',
+        completed: 'Completado',
+        cancelled: 'Cancelado',
+      },
+      noTargets: 'Solo agrupación',
+      noTargetsHint: 'Creado antes de que existieran las cantidades por bandeja, así que sigue las impresiones en cola en lugar de un objetivo.',
+      dispatchRemaining: 'Encolar {{count}} restantes',
+      dispatchPlate: 'Encolar restantes',
+      dispatched: 'Impresiones restantes encoladas para {{name}}',
+      plateProgress: '{{completed}} de {{target}} hechas',
+      remaining: '{{count}} pendientes',
+      failed: '{{count}} fallidas',
+      printing: '{{count}} imprimiendo',
+      wholeFile: 'Archivo completo',
+      due: 'Vence {{date}}',
+      costSoFar: '{{amount}} hasta ahora',
+      costRemaining: '({{amount}} por gastar)',
+    },
     activeBatches: 'Lotes activos',
     batchProgress: '{{completed}} de {{total}} completados',
     cancelBatch: 'Cancelar los restantes',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: 'Arrastrar grupo',
     },
     tabs: {
+      batches: 'Lotes',
       queue: 'Cola',
       history: 'Historial',
       timeline: 'Cronología',
@@ -3299,6 +3336,34 @@ export default {
     eta: 'Tiempo estimado',
     printerIdle: 'La impresora está inactiva',
     printerOffline: 'Impresora desconectada',
+    builder: {
+      title: 'Superposición de emisión',
+      description: 'Crea la URL de una superposición de emisión: una vista de cámara a pantalla completa con los datos de impresión en directo encima, para OBS, una pantalla de pared o cualquier fuente de navegador. Elige los campos y copia la URL.',
+      printer: 'Impresora',
+      size: 'Tamaño del texto',
+      sizeSmall: 'Pequeño',
+      sizeMedium: 'Mediano',
+      sizeLarge: 'Grande',
+      fps: 'Fotogramas por segundo',
+      fpsHint: 'Las cámaras A1 y P1 no pasan de unos 5 fps, sea cual sea el valor solicitado.',
+      token: 'Token de superposición (opcional)',
+      tokenHint: 'Solo hace falta si el inicio de sesión está activado: OBS no tiene sesión propia. Crea uno arriba con el ámbito Superposición de emisión.',
+      tokenWarning: 'Esta URL contiene un token: cualquiera que pueda leerla verá la emisión y el nombre del archivo. Revoca el token para cortar el acceso.',
+      fields: 'Campos que mostrar',
+      fieldPrinter: 'Nombre de la impresora',
+      fieldFilename: 'Nombre del archivo',
+      fieldStatus: 'Estado',
+      fieldProgress: 'Barra de progreso',
+      fieldLayers: 'Número de capas',
+      fieldEta: 'Tiempo restante y hora de fin',
+      fieldCamera: 'Imagen de la cámara',
+      chamberHint: 'La temperatura de la cámara de impresión solo aparece en modelos con sensor real: las P1 y A1 informan un valor sin sentido, así que se omite.',
+      urlTitle: 'URL de la superposición',
+      open: 'Abrir',
+      showPreview: 'Mostrar vista previa',
+      hidePreview: 'Ocultar vista previa',
+      previewTitle: 'Vista previa de la superposición',
+    },
     status: {
       printing: 'Imprimiendo',
       paused: 'En pausa',
@@ -6549,6 +6614,10 @@ export default {
         title: 'Servicio de detección (puerto {{port}})',
         fail: 'No hay nada escuchando en el puerto {{port}} de la IP de enlace, por lo que falla el protocolo de detección del laminador.',
       },
+      privileged_ports: {
+        title: 'Vinculación a puertos privilegiados',
+        fail: 'El puerto {{port}} está por debajo de 1024 y este servicio no tiene permiso para vincularlo, por eso no hay nada escuchando arriba. Añade AmbientCapabilities=CAP_NET_BIND_SERVICE a /etc/systemd/system/bambuddy.service y reinicia, o ejecuta "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". En Docker, añade cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: 'Certificado TLS',
         pass: 'Certificado listo. Asegúrese de que el certificado de CA de Bambuddy (arriba) esté importado en el almacén de confianza de su laminador.',

+ 69 - 0
frontend/src/i18n/locales/fr.ts

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filaments',
+    externalSpool: {
+      hide: 'Masquer la bobine externe',
+      show: 'Afficher la bobine externe',
+    },
     // Camera
     openCameraOverlay: 'Ouvrir la caméra en superposition',
     openCameraWindow: 'Ouvrir la caméra dans une fenêtre',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantité',
     quantityHint: 'Crée {{count}} éléments de file d\'attente',
+    plateQuantityLabel: 'Impressions de {{plate}}',
+    plateQuantityTotal_one: '{{count}} impression au total',
+    plateQuantityTotal_other: '{{count}} impressions au total',
+    batchOrders: {
+      title: 'Commandes par lot',
+      emptyTitle: 'Aucune commande par lot',
+      emptyDescription: 'Mettez en file un fichier multi-plateaux, ou plusieurs copies d\'un plateau, et la commande apparaît ici avec son avancement.',
+      filter: {
+        active: 'Actives',
+        completed: 'Terminées',
+        cancelled: 'Annulées',
+        all: 'Toutes',
+      },
+      status: {
+        active: 'Active',
+        completed: 'Terminée',
+        cancelled: 'Annulée',
+      },
+      noTargets: 'Regroupement seul',
+      noTargetsHint: 'Créée avant les quantités par plateau : elle suit les impressions mises en file plutôt qu\'un objectif.',
+      dispatchRemaining: 'Mettre en file les {{count}} restantes',
+      dispatchPlate: 'Mettre le reste en file',
+      dispatched: 'Impressions restantes mises en file pour {{name}}',
+      plateProgress: '{{completed}} sur {{target}} faites',
+      remaining: '{{count}} encore dues',
+      failed: '{{count}} échouées',
+      printing: '{{count}} en cours',
+      wholeFile: 'Fichier entier',
+      due: 'Échéance {{date}}',
+      costSoFar: '{{amount}} à ce jour',
+      costRemaining: '({{amount}} à venir)',
+    },
     activeBatches: 'Lots actifs',
     batchProgress: '{{completed}} sur {{total}} terminés',
     cancelBatch: 'Annuler les restants',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: 'Faire glisser le groupe',
     },
     tabs: {
+      batches: 'Lots',
       queue: 'File',
       history: 'Historique',
       timeline: 'Chronologie',
@@ -3285,6 +3322,34 @@ export default {
     eta: 'Fin estimée',
     printerIdle: 'Imprimante inactive',
     printerOffline: 'Imprimante hors ligne',
+    builder: {
+      title: 'Incrustation de diffusion',
+      description: 'Compose l\'URL d\'une incrustation de diffusion — une vue caméra plein écran avec les données d\'impression en direct par-dessus, pour OBS, un écran mural ou toute source navigateur. Choisissez les champs voulus et copiez l\'URL.',
+      printer: 'Imprimante',
+      size: 'Taille du texte',
+      sizeSmall: 'Petite',
+      sizeMedium: 'Moyenne',
+      sizeLarge: 'Grande',
+      fps: 'Fréquence d\'images',
+      fpsHint: 'Les caméras A1 et P1 plafonnent autour de 5 images par seconde, quelle que soit la valeur demandée.',
+      token: 'Jeton d\'incrustation (facultatif)',
+      tokenHint: 'Nécessaire uniquement si la connexion est activée : OBS n\'a pas de session. Créez-en un ci-dessus avec la portée Incrustation de diffusion.',
+      tokenWarning: 'Cette URL contient un jeton — quiconque peut la lire peut voir le flux et le nom du fichier. Révoquez le jeton pour couper l\'accès.',
+      fields: 'Champs à afficher',
+      fieldPrinter: 'Nom de l\'imprimante',
+      fieldFilename: 'Nom du fichier',
+      fieldStatus: 'Statut',
+      fieldProgress: 'Barre de progression',
+      fieldLayers: 'Nombre de couches',
+      fieldEta: 'Temps restant et heure de fin',
+      fieldCamera: 'Flux caméra',
+      chamberHint: 'La température du caisson n\'apparaît que sur les modèles dotés d\'un vrai capteur — les P1 et A1 renvoient une valeur sans signification, elle est donc omise.',
+      urlTitle: 'URL de l\'incrustation',
+      open: 'Ouvrir',
+      showPreview: 'Afficher l\'aperçu',
+      hidePreview: 'Masquer l\'aperçu',
+      previewTitle: 'Aperçu de l\'incrustation',
+    },
     status: {
       printing: 'Impression',
       paused: 'En pause',
@@ -6530,6 +6595,10 @@ export default {
         title: 'Service de détection (port {{port}})',
         fail: 'Rien n\'écoute sur le port {{port}} de l\'IP de liaison, la poignée de main de détection du slicer échoue donc.',
       },
+      privileged_ports: {
+        title: 'Liaison aux ports privilégiés',
+        fail: 'Le port {{port}} est inférieur à 1024 et ce service n\'est pas autorisé à s\'y lier — c\'est pourquoi rien n\'écoute ci-dessus. Ajoutez AmbientCapabilities=CAP_NET_BIND_SERVICE dans /etc/systemd/system/bambuddy.service puis redémarrez, ou exécutez "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". Sous Docker, ajoutez cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: 'Certificat TLS',
         pass: 'Certificat prêt. Assurez-vous que le certificat CA Bambuddy (ci-dessus) est importé dans le magasin de confiance de votre slicer.',

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

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamenti',
+    externalSpool: {
+      hide: 'Nascondi bobina esterna',
+      show: 'Mostra bobina esterna',
+    },
     // Camera
     openCameraOverlay: 'Apri overlay camera',
     openCameraWindow: 'Apri camera in nuova finestra',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantità',
     quantityHint: 'Crea {{count}} elementi in coda',
+    plateQuantityLabel: 'Stampe di {{plate}}',
+    plateQuantityTotal_one: '{{count}} stampa in totale',
+    plateQuantityTotal_other: '{{count}} stampe in totale',
+    batchOrders: {
+      title: 'Ordini in lotto',
+      emptyTitle: 'Nessun ordine in lotto',
+      emptyDescription: 'Metti in coda un file multi-piatto, o più copie di un piatto, e l\'ordine compare qui con il suo avanzamento.',
+      filter: {
+        active: 'Attivi',
+        completed: 'Completati',
+        cancelled: 'Annullati',
+        all: 'Tutti',
+      },
+      status: {
+        active: 'Attivo',
+        completed: 'Completato',
+        cancelled: 'Annullato',
+      },
+      noTargets: 'Solo raggruppamento',
+      noTargetsHint: 'Creato prima delle quantità per piatto: tiene traccia delle stampe in coda anziché di un obiettivo.',
+      dispatchRemaining: 'Accoda {{count}} rimanenti',
+      dispatchPlate: 'Accoda le rimanenti',
+      dispatched: 'Stampe rimanenti accodate per {{name}}',
+      plateProgress: '{{completed}} di {{target}} fatte',
+      remaining: '{{count}} ancora dovute',
+      failed: '{{count}} fallite',
+      printing: '{{count}} in stampa',
+      wholeFile: 'Intero file',
+      due: 'Scadenza {{date}}',
+      costSoFar: '{{amount}} finora',
+      costRemaining: '({{amount}} da spendere)',
+    },
     activeBatches: 'Lotti attivi',
     batchProgress: '{{completed}} di {{total}} completati',
     cancelBatch: 'Annulla rimanenti',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: 'Trascina gruppo',
     },
     tabs: {
+      batches: 'Lotti',
       queue: 'Coda',
       history: 'Cronologia',
       timeline: 'Linea temporale',
@@ -3284,6 +3321,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Stampante inattiva',
     printerOffline: 'Stampante offline',
+    builder: {
+      title: 'Overlay per streaming',
+      description: 'Compone l\'URL di un overlay per streaming: una vista telecamera a schermo intero con i dati di stampa in tempo reale sovrapposti, per OBS, un display a parete o qualsiasi sorgente browser. Scegli i campi e copia l\'URL.',
+      printer: 'Stampante',
+      size: 'Dimensione del testo',
+      sizeSmall: 'Piccola',
+      sizeMedium: 'Media',
+      sizeLarge: 'Grande',
+      fps: 'Frequenza fotogrammi',
+      fpsHint: 'Le telecamere A1 e P1 si fermano intorno a 5 fps, qualunque valore venga richiesto.',
+      token: 'Token overlay (facoltativo)',
+      tokenHint: 'Serve solo con il login attivo: OBS non ha una sessione propria. Creane uno sopra con ambito Overlay per streaming.',
+      tokenWarning: 'Questo URL contiene un token: chi riesce a leggerlo può vedere lo streaming e il nome del file. Revoca il token per interrompere l\'accesso.',
+      fields: 'Campi da mostrare',
+      fieldPrinter: 'Nome stampante',
+      fieldFilename: 'Nome file',
+      fieldStatus: 'Stato',
+      fieldProgress: 'Barra di avanzamento',
+      fieldLayers: 'Numero di layer',
+      fieldEta: 'Tempo rimanente e orario di fine',
+      fieldCamera: 'Immagine telecamera',
+      chamberHint: 'La temperatura della camera compare solo sui modelli con un vero sensore: P1 e A1 riportano un valore privo di significato, quindi viene omessa.',
+      urlTitle: 'URL overlay',
+      open: 'Apri',
+      showPreview: 'Mostra anteprima',
+      hidePreview: 'Nascondi anteprima',
+      previewTitle: 'Anteprima overlay',
+    },
     status: {
       printing: 'In stampa',
       paused: 'In pausa',
@@ -6529,6 +6594,10 @@ export default {
         title: 'Servizio di rilevamento (porta {{port}})',
         fail: 'Nulla è in ascolto sulla porta {{port}} dell\'IP di binding, quindi l\'handshake di rilevamento dello slicer fallisce.',
       },
+      privileged_ports: {
+        title: 'Binding sulle porte privilegiate',
+        fail: 'La porta {{port}} è sotto 1024 e questo servizio non è autorizzato ad associarla: per questo sopra non risulta nulla in ascolto. Aggiungi AmbientCapabilities=CAP_NET_BIND_SERVICE in /etc/systemd/system/bambuddy.service e riavvia, oppure esegui "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". Con Docker aggiungi cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: 'Certificato TLS',
         pass: 'Certificato pronto. Assicurati che il certificato CA di Bambuddy (sopra) sia importato nell\'archivio attendibile del tuo slicer.',

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

@@ -617,6 +617,10 @@ export default {
     },
     // Filaments section
     filaments: 'フィラメント',
+    externalSpool: {
+      hide: '外部スプールを非表示にする',
+      show: '外部スプールを表示する',
+    },
     // Camera
     openCameraOverlay: 'カメラオーバーレイを開く',
     openCameraWindow: 'カメラを新しいウィンドウで開く',
@@ -1197,6 +1201,38 @@ export default {
     // Batch / quantity
     quantity: '数量',
     quantityHint: '{{count}}件のキューアイテムを作成',
+    plateQuantityLabel: '{{plate}} の実行回数',
+    plateQuantityTotal_one: '合計 {{count}} 回',
+    plateQuantityTotal_other: '合計 {{count}} 回',
+    batchOrders: {
+      title: 'バッチ注文',
+      emptyTitle: 'バッチ注文はありません',
+      emptyDescription: '複数プレートのファイル、または同じプレートを複数回キューに入れると、注文がここに進捗とともに表示されます。',
+      filter: {
+        active: '進行中',
+        completed: '完了',
+        cancelled: 'キャンセル済み',
+        all: 'すべて',
+      },
+      status: {
+        active: '進行中',
+        completed: '完了',
+        cancelled: 'キャンセル済み',
+      },
+      noTargets: 'グループのみ',
+      noTargetsHint: 'プレートごとの目標数が導入される前に作成されたため、目標ではなくキューに入れた実行を追跡します。',
+      dispatchRemaining: '残り{{count}}件をキューに追加',
+      dispatchPlate: '残りをキューに追加',
+      dispatched: '{{name}} の残りの実行をキューに追加しました',
+      plateProgress: '{{target}}件中{{completed}}件完了',
+      remaining: '残り{{count}}件',
+      failed: '{{count}}件失敗',
+      printing: '{{count}}件印刷中',
+      wholeFile: 'ファイル全体',
+      due: '期限 {{date}}',
+      costSoFar: 'これまで {{amount}}',
+      costRemaining: '(残り {{amount}})',
+    },
     activeBatches: 'アクティブなバッチ',
     batchProgress: '{{total}}件中{{completed}}件完了',
     cancelBatch: '残りをキャンセル',
@@ -1222,6 +1258,7 @@ export default {
       dragGroup: 'グループをドラッグ',
     },
     tabs: {
+      batches: 'バッチ',
       queue: 'キュー',
       history: '履歴',
       timeline: 'タイムライン',
@@ -3296,6 +3333,34 @@ export default {
     eta: '残り時間',
     printerIdle: 'プリンター待機中',
     printerOffline: 'プリンターオフライン',
+    builder: {
+      title: 'ストリームオーバーレイ',
+      description: 'ストリームオーバーレイのURLを作成します。全画面のカメラ映像に印刷中の情報を重ねて表示するもので、OBSや壁掛けディスプレイなどのブラウザソースで使えます。表示する項目を選んでURLをコピーしてください。',
+      printer: 'プリンター',
+      size: '文字サイズ',
+      sizeSmall: '小',
+      sizeMedium: '中',
+      sizeLarge: '大',
+      fps: 'フレームレート',
+      fpsHint: 'A1およびP1のカメラは、指定した値にかかわらず毎秒5フレーム程度が上限です。',
+      token: 'ストリームオーバーレイトークン(任意)',
+      tokenHint: 'ログインを有効にしている場合のみ必要です。OBS自体はセッションを持ちません。上の欄でストリームオーバーレイのスコープを選んで作成してください。',
+      tokenWarning: 'このURLにはトークンが含まれます。URLを読める人は誰でも映像とファイル名を見られます。アクセスを止めるにはトークンを失効させてください。',
+      fields: '表示する項目',
+      fieldPrinter: 'プリンター名',
+      fieldFilename: 'ファイル名',
+      fieldStatus: 'ステータス',
+      fieldProgress: '進捗バー',
+      fieldLayers: 'レイヤー数',
+      fieldEta: '残り時間と終了予定時刻',
+      fieldCamera: 'カメラ映像',
+      chamberHint: 'チャンバー温度は実際のセンサーを備えたモデルでのみ表示されます。P1およびA1は意味のない値を返すため除外されます。',
+      urlTitle: 'オーバーレイURL',
+      open: '開く',
+      showPreview: 'プレビューを表示',
+      hidePreview: 'プレビューを非表示',
+      previewTitle: 'オーバーレイのプレビュー',
+    },
     status: {
       printing: '印刷中',
       paused: '一時停止',
@@ -6541,6 +6606,10 @@ export default {
         title: '検出サービス(ポート {{port}})',
         fail: 'バインド IP のポート {{port}} で待ち受けているものがないため、スライサーの検出ハンドシェイクが失敗します。',
       },
+      privileged_ports: {
+        title: '特権ポートへのバインド',
+        fail: 'ポート {{port}} は 1024 未満で、このサービスにはバインドする権限がありません。上で何も待ち受けていないのはこのためです。/etc/systemd/system/bambuddy.service に AmbientCapabilities=CAP_NET_BIND_SERVICE を追加して再起動するか、"sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))" を実行してください。Docker の場合は cap_add: [NET_BIND_SERVICE] を追加します。',
+      },
       certificate: {
         title: 'TLS 証明書',
         pass: '証明書の準備ができています。Bambuddy CA 証明書(上記)がスライサーの信頼ストアにインポートされていることを確認してください。',

+ 69 - 0
frontend/src/i18n/locales/ko.ts

@@ -580,6 +580,10 @@ export default {
       external: '외부 스풀',
     },
     filaments: '필라멘트',
+    externalSpool: {
+      hide: '외부 스풀 숨기기',
+      show: '외부 스풀 표시',
+    },
     openCameraOverlay: '카메라 오버레이 열기',
     openCameraWindow: '새 창에서 카메라 열기',
     firmwareUpdateAvailable: '펌웨어 업데이트 가능: {{current}} → {{latest}}',
@@ -1136,6 +1140,38 @@ export default {
     plateNumber: '플레이트 {{index}}',
     quantity: '수량',
     quantityHint: '{{count}}개 대기열 항목 생성',
+    plateQuantityLabel: '{{plate}} 실행 횟수',
+    plateQuantityTotal_one: '총 {{count}}회',
+    plateQuantityTotal_other: '총 {{count}}회',
+    batchOrders: {
+      title: '배치 주문',
+      emptyTitle: '배치 주문 없음',
+      emptyDescription: '여러 플레이트 파일이나 한 플레이트의 여러 복사본을 대기열에 넣으면 주문이 진행 상황과 함께 여기에 표시됩니다.',
+      filter: {
+        active: '진행 중',
+        completed: '완료',
+        cancelled: '취소됨',
+        all: '전체',
+      },
+      status: {
+        active: '진행 중',
+        completed: '완료',
+        cancelled: '취소됨',
+      },
+      noTargets: '그룹만',
+      noTargetsHint: '플레이트별 목표 수량이 도입되기 전에 생성되어 목표가 아닌 대기열에 넣은 실행을 추적합니다.',
+      dispatchRemaining: '남은 {{count}}개 대기열 추가',
+      dispatchPlate: '남은 항목 추가',
+      dispatched: '{{name}}의 남은 실행을 대기열에 추가했습니다',
+      plateProgress: '{{target}}개 중 {{completed}}개 완료',
+      remaining: '{{count}}개 남음',
+      failed: '{{count}}개 실패',
+      printing: '{{count}}개 인쇄 중',
+      wholeFile: '파일 전체',
+      due: '기한 {{date}}',
+      costSoFar: '현재까지 {{amount}}',
+      costRemaining: '({{amount}} 남음)',
+    },
     activeBatches: '활성 배치',
     batchProgress: '{{total}}개 중 {{completed}}개 완료',
     cancelBatch: '나머지 취소',
@@ -1161,6 +1197,7 @@ export default {
       dragGroup: '그룹 드래그',
     },
     tabs: {
+      batches: '배치',
       queue: '큐',
       history: '기록',
       timeline: '타임라인',
@@ -3122,6 +3159,34 @@ export default {
     eta: '예상 완료',
     printerIdle: '프린터 대기 중',
     printerOffline: '프린터 오프라인',
+    builder: {
+      title: '스트림 오버레이',
+      description: '스트림 오버레이 URL을 만듭니다. 전체 화면 카메라 영상 위에 실시간 출력 정보를 겹쳐 보여주며 OBS, 벽걸이 디스플레이 등 모든 브라우저 소스에서 쓸 수 있습니다. 원하는 항목을 고르고 URL을 복사하세요.',
+      printer: '프린터',
+      size: '글자 크기',
+      sizeSmall: '작게',
+      sizeMedium: '보통',
+      sizeLarge: '크게',
+      fps: '프레임 속도',
+      fpsHint: 'A1 및 P1 카메라는 요청한 값과 관계없이 초당 약 5프레임이 한계입니다.',
+      token: '스트림 오버레이 토큰(선택)',
+      tokenHint: '로그인을 사용할 때만 필요합니다. OBS에는 자체 세션이 없습니다. 위에서 스트림 오버레이 범위로 발급하세요.',
+      tokenWarning: '이 URL에는 토큰이 들어 있습니다. URL을 읽을 수 있는 사람은 누구나 영상과 파일 이름을 볼 수 있습니다. 접근을 끊으려면 토큰을 폐기하세요.',
+      fields: '표시할 항목',
+      fieldPrinter: '프린터 이름',
+      fieldFilename: '파일 이름',
+      fieldStatus: '상태',
+      fieldProgress: '진행률 막대',
+      fieldLayers: '레이어 수',
+      fieldEta: '남은 시간과 완료 예정 시각',
+      fieldCamera: '카메라 영상',
+      chamberHint: '챔버 온도는 실제 센서가 있는 모델에서만 표시됩니다. P1과 A1은 의미 없는 값을 보고하므로 제외됩니다.',
+      urlTitle: '오버레이 URL',
+      open: '열기',
+      showPreview: '미리보기 표시',
+      hidePreview: '미리보기 숨기기',
+      previewTitle: '오버레이 미리보기',
+    },
     status: {
       printing: '인쇄 중',
       paused: '일시 중지됨',
@@ -6612,6 +6677,10 @@ export default {
         title: '검색 서비스 (포트 {{port}})',
         fail: '바인드 IP의 포트 {{port}}에서 수신 중인 서비스가 없어 슬라이서의 검색 핸드셰이크가 실패합니다.'
       },
+      privileged_ports: {
+        title: '특권 포트 바인딩',
+        fail: '포트 {{port}}은(는) 1024 미만이며 이 서비스에는 바인딩 권한이 없습니다. 위에서 아무것도 수신 대기하지 않는 이유입니다. /etc/systemd/system/bambuddy.service에 AmbientCapabilities=CAP_NET_BIND_SERVICE를 추가하고 재시작하거나 "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))"를 실행하세요. Docker에서는 cap_add: [NET_BIND_SERVICE]를 추가합니다.',
+      },
       certificate: {
         title: 'TLS 인증서',
         pass: '인증서가 준비됐습니다. Bambuddy CA 인증서(위)가 슬라이서의 신뢰 저장소에 가져와져 있는지 확인하세요.',

+ 69 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: 'Filamentos',
+    externalSpool: {
+      hide: 'Ocultar bobina externa',
+      show: 'Mostrar bobina externa',
+    },
     // Camera
     openCameraOverlay: 'Abrir sobreposição da câmera',
     openCameraWindow: 'Abrir câmera em nova janela',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: 'Quantidade',
     quantityHint: 'Cria {{count}} itens na fila',
+    plateQuantityLabel: 'Impressões de {{plate}}',
+    plateQuantityTotal_one: '{{count}} impressão no total',
+    plateQuantityTotal_other: '{{count}} impressões no total',
+    batchOrders: {
+      title: 'Pedidos em lote',
+      emptyTitle: 'Nenhum pedido em lote',
+      emptyDescription: 'Coloque na fila um arquivo com várias mesas, ou mais de uma cópia de uma mesa, e o pedido aparece aqui com o progresso.',
+      filter: {
+        active: 'Ativos',
+        completed: 'Concluídos',
+        cancelled: 'Cancelados',
+        all: 'Todos',
+      },
+      status: {
+        active: 'Ativo',
+        completed: 'Concluído',
+        cancelled: 'Cancelado',
+      },
+      noTargets: 'Apenas agrupamento',
+      noTargetsHint: 'Criado antes das quantidades por mesa, então acompanha as impressões enfileiradas em vez de uma meta.',
+      dispatchRemaining: 'Enfileirar {{count}} restantes',
+      dispatchPlate: 'Enfileirar restantes',
+      dispatched: 'Impressões restantes enfileiradas para {{name}}',
+      plateProgress: '{{completed}} de {{target}} prontas',
+      remaining: '{{count}} ainda devidas',
+      failed: '{{count}} falharam',
+      printing: '{{count}} imprimindo',
+      wholeFile: 'Arquivo inteiro',
+      due: 'Vence {{date}}',
+      costSoFar: '{{amount}} até agora',
+      costRemaining: '({{amount}} a gastar)',
+    },
     activeBatches: 'Lotes ativos',
     batchProgress: '{{completed}} de {{total}} concluídos',
     cancelBatch: 'Cancelar restantes',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: 'Arrastar grupo',
     },
     tabs: {
+      batches: 'Lotes',
       queue: 'Fila',
       history: 'Histórico',
       timeline: 'Linha do tempo',
@@ -3284,6 +3321,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Impressora ociosa',
     printerOffline: 'Impressora offline',
+    builder: {
+      title: 'Sobreposição de transmissão',
+      description: 'Monta a URL de uma sobreposição de transmissão: a câmera em tela cheia com os dados da impressão sobrepostos, para OBS, um painel de parede ou qualquer fonte de navegador. Escolha os campos e copie a URL.',
+      printer: 'Impressora',
+      size: 'Tamanho do texto',
+      sizeSmall: 'Pequeno',
+      sizeMedium: 'Médio',
+      sizeLarge: 'Grande',
+      fps: 'Taxa de quadros',
+      fpsHint: 'Câmeras A1 e P1 chegam no máximo a cerca de 5 fps, qualquer que seja o valor pedido.',
+      token: 'Token de sobreposição (opcional)',
+      tokenHint: 'Só é necessário com login ativado: o OBS não tem sessão própria. Crie um acima com o escopo Sobreposição de transmissão.',
+      tokenWarning: 'Esta URL contém um token: quem conseguir lê-la pode assistir à transmissão e ver o nome do arquivo. Revogue o token para cortar o acesso.',
+      fields: 'Campos a exibir',
+      fieldPrinter: 'Nome da impressora',
+      fieldFilename: 'Nome do arquivo',
+      fieldStatus: 'Status',
+      fieldProgress: 'Barra de progresso',
+      fieldLayers: 'Contagem de camadas',
+      fieldEta: 'Tempo restante e previsão de término',
+      fieldCamera: 'Imagem da câmera',
+      chamberHint: 'A temperatura da câmara aparece apenas em modelos com sensor real: P1 e A1 informam um valor sem sentido, por isso ela é omitida.',
+      urlTitle: 'URL da sobreposição',
+      open: 'Abrir',
+      showPreview: 'Mostrar prévia',
+      hidePreview: 'Ocultar prévia',
+      previewTitle: 'Prévia da sobreposição',
+    },
     status: {
       printing: 'Imprimindo',
       paused: 'Pausado',
@@ -6529,6 +6594,10 @@ export default {
         title: 'Serviço de descoberta (porta {{port}})',
         fail: 'Nada está escutando na porta {{port}} do IP de vínculo, então a negociação de descoberta do slicer falha.',
       },
+      privileged_ports: {
+        title: 'Vinculação a portas privilegiadas',
+        fail: 'A porta {{port}} está abaixo de 1024 e este serviço não tem permissão para vinculá-la, por isso nada está escutando acima. Adicione AmbientCapabilities=CAP_NET_BIND_SERVICE em /etc/systemd/system/bambuddy.service e reinicie, ou execute "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". No Docker, adicione cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: 'Certificado TLS',
         pass: 'Certificado pronto. Verifique se o certificado CA do Bambuddy (acima) está importado no armazenamento de confiança do seu slicer.',

+ 69 - 0
frontend/src/i18n/locales/ru.ts

@@ -585,6 +585,10 @@ export default {
       external: "Внешняя катушка",
     },
     filaments: "Филаменты",
+    externalSpool: {
+      hide: "Скрыть внешнюю катушку",
+      show: "Показать внешнюю катушку",
+    },
     openCameraOverlay: "Открыть камеру поверх интерфейса",
     openCameraWindow: "Открыть камеру в новом окне",
     firmwareUpdateAvailable: "Доступно обновление прошивки: {{current}} → {{latest}}",
@@ -1146,6 +1150,38 @@ export default {
     plateNumber: "Пластина {{index}}",
     quantity: "Количество",
     quantityHint: "Будет создано заданий: {{count}}",
+    plateQuantityLabel: "Повторы: {{plate}}",
+    plateQuantityTotal_one: "Всего запусков: {{count}}",
+    plateQuantityTotal_other: "Всего запусков: {{count}}",
+    batchOrders: {
+      title: "Пакетные заказы",
+      emptyTitle: "Пакетных заказов нет",
+      emptyDescription: "Поставьте в очередь файл с несколькими столами или несколько копий одного стола — заказ появится здесь вместе с прогрессом.",
+      filter: {
+        active: "Активные",
+        completed: "Завершённые",
+        cancelled: "Отменённые",
+        all: "Все",
+      },
+      status: {
+        active: "Активен",
+        completed: "Завершён",
+        cancelled: "Отменён",
+      },
+      noTargets: "Только группировка",
+      noTargetsHint: "Создан до появления целевых количеств по столам, поэтому отслеживает поставленные в очередь запуски, а не цель.",
+      dispatchRemaining: "Поставить в очередь: {{count}}",
+      dispatchPlate: "Поставить остаток",
+      dispatched: "Оставшиеся запуски для «{{name}}» поставлены в очередь",
+      plateProgress: "Готово {{completed}} из {{target}}",
+      remaining: "Осталось: {{count}}",
+      failed: "Неудачных: {{count}}",
+      printing: "Печатается: {{count}}",
+      wholeFile: "Весь файл",
+      due: "Срок: {{date}}",
+      costSoFar: "{{amount}} потрачено",
+      costRemaining: "(ещё {{amount}})",
+    },
     activeBatches: "Активные партии",
     batchProgress: "Выполнено {{completed}} из {{total}}",
     cancelBatch: "Отменить оставшиеся",
@@ -1171,6 +1207,7 @@ export default {
       dragGroup: "Перетащить группу",
     },
     tabs: {
+      batches: "Партии",
       queue: "Очередь",
       history: "История",
       timeline: "Временная шкала",
@@ -3114,6 +3151,34 @@ export default {
     eta: "Осталось",
     printerIdle: "Принтер простаивает",
     printerOffline: "Принтер не в сети",
+    builder: {
+      title: "Оформление трансляции",
+      description: "Собирает адрес оформления трансляции: полноэкранное изображение камеры с наложенными данными о печати — для OBS, настенного экрана или любого источника-браузера. Выберите нужные поля и скопируйте адрес.",
+      printer: "Принтер",
+      size: "Размер текста",
+      sizeSmall: "Мелкий",
+      sizeMedium: "Средний",
+      sizeLarge: "Крупный",
+      fps: "Частота кадров",
+      fpsHint: "Камеры A1 и P1 выдают не более примерно 5 кадров в секунду, какое бы значение вы ни задали.",
+      token: "Токен оформления трансляции (необязательно)",
+      tokenHint: "Нужен только при включённом входе: у OBS нет собственного сеанса. Создайте его выше с областью «Оформление трансляции».",
+      tokenWarning: "Этот адрес содержит токен: любой, кто его прочитает, увидит трансляцию и имя файла. Отзовите токен, чтобы закрыть доступ.",
+      fields: "Показываемые поля",
+      fieldPrinter: "Имя принтера",
+      fieldFilename: "Имя файла",
+      fieldStatus: "Состояние",
+      fieldProgress: "Полоса прогресса",
+      fieldLayers: "Количество слоёв",
+      fieldEta: "Оставшееся время и время окончания",
+      fieldCamera: "Изображение камеры",
+      chamberHint: "Температура камеры показывается только на моделях с настоящим датчиком: P1 и A1 сообщают бессмысленное значение, поэтому она опускается.",
+      urlTitle: "Адрес оформления",
+      open: "Открыть",
+      showPreview: "Показать предпросмотр",
+      hidePreview: "Скрыть предпросмотр",
+      previewTitle: "Предпросмотр оформления",
+    },
     status: {
       printing: "Печать",
       paused: "Приостановлено",
@@ -6169,6 +6234,10 @@ export default {
         title: "Служба обнаружения (порт {{port}})",
         fail: "На порту {{port}} выбранного IP-адреса никто не слушает, поэтому сетевое обнаружение слайсером не работает.",
       },
+      privileged_ports: {
+        title: 'Привязка к привилегированным портам',
+        fail: 'Порт {{port}} ниже 1024, и этой службе не разрешено его занимать — поэтому выше ничего не слушает. Добавьте AmbientCapabilities=CAP_NET_BIND_SERVICE в /etc/systemd/system/bambuddy.service и перезапустите либо выполните "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". В Docker добавьте cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: "Сертификат TLS",
         pass: "Сертификат готов. Убедитесь, что сертификат центра сертификации Bambuddy, указанный выше, импортирован в доверенное хранилище слайсера.",

+ 69 - 0
frontend/src/i18n/locales/tr.ts

@@ -618,6 +618,10 @@ export default {
     },
     // Filamentler bölümü
     filaments: 'Filamentler',
+    externalSpool: {
+      hide: 'Harici makarayı gizle',
+      show: 'Harici makarayı göster',
+    },
     // Kamera
     openCameraOverlay: 'Kamera bindirmesini aç',
     openCameraWindow: 'Kamerayı yeni pencerede aç',
@@ -1198,6 +1202,38 @@ export default {
     // Toplu / miktar
     quantity: 'Miktar',
     quantityHint: '{{count}} kuyruk öğesi oluşturur',
+    plateQuantityLabel: '{{plate}} baskı sayısı',
+    plateQuantityTotal_one: 'toplam {{count}} baskı',
+    plateQuantityTotal_other: 'toplam {{count}} baskı',
+    batchOrders: {
+      title: 'Toplu siparişler',
+      emptyTitle: 'Toplu sipariş yok',
+      emptyDescription: 'Çok tablalı bir dosyayı ya da bir tablanın birden fazla kopyasını kuyruğa alın; sipariş ilerlemesiyle birlikte burada görünür.',
+      filter: {
+        active: 'Etkin',
+        completed: 'Tamamlanan',
+        cancelled: 'İptal edilen',
+        all: 'Tümü',
+      },
+      status: {
+        active: 'Etkin',
+        completed: 'Tamamlandı',
+        cancelled: 'İptal edildi',
+      },
+      noTargets: 'Yalnızca gruplama',
+      noTargetsHint: 'Tabla başına hedef adetler gelmeden önce oluşturuldu; hedef yerine kuyruğa alınan baskıları izler.',
+      dispatchRemaining: 'Kalan {{count}} baskıyı kuyruğa al',
+      dispatchPlate: 'Kalanları kuyruğa al',
+      dispatched: '{{name}} için kalan baskılar kuyruğa alındı',
+      plateProgress: '{{target}} baskıdan {{completed}} tamam',
+      remaining: '{{count}} baskı bekliyor',
+      failed: '{{count}} başarısız',
+      printing: '{{count}} yazdırılıyor',
+      wholeFile: 'Dosyanın tamamı',
+      due: 'Son tarih {{date}}',
+      costSoFar: 'şu ana kadar {{amount}}',
+      costRemaining: '({{amount}} kaldı)',
+    },
     activeBatches: 'Aktif Yığınlar',
     batchProgress: '{{total}} öğeden {{completed}} tanesi tamamlandı',
     cancelBatch: 'Kalanları İptal Et',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: 'Grubu sürükle',
     },
     tabs: {
+      batches: 'Gruplar',
       queue: 'Kuyruk',
       history: 'Geçmiş',
       timeline: 'Zaman çizelgesi',
@@ -3300,6 +3337,34 @@ export default {
     eta: 'ETA',
     printerIdle: 'Yazıcı boşta',
     printerOffline: 'Yazıcı çevrimdışı',
+    builder: {
+      title: 'Yayın Kaplaması',
+      description: 'Yayın kaplaması için URL oluşturur: tam ekran kamera görüntüsünün üzerine canlı baskı bilgileri bindirilir; OBS, duvar ekranı veya herhangi bir tarayıcı kaynağı için. İstediğiniz alanları seçip URL\'yi kopyalayın.',
+      printer: 'Yazıcı',
+      size: 'Yazı boyutu',
+      sizeSmall: 'Küçük',
+      sizeMedium: 'Orta',
+      sizeLarge: 'Büyük',
+      fps: 'Kare hızı',
+      fpsHint: 'A1 ve P1 kameraları, hangi değeri isterseniz isteyin saniyede yaklaşık 5 karede kalır.',
+      token: 'Yayın Kaplaması belirteci (isteğe bağlı)',
+      tokenHint: 'Yalnızca oturum açma etkinken gerekir: OBS\'nin kendi oturumu yoktur. Yukarıdan Yayın Kaplaması kapsamıyla bir tane oluşturun.',
+      tokenWarning: 'Bu URL bir belirteç içerir: okuyabilen herkes yayını izleyebilir ve dosya adını görebilir. Erişimi kesmek için belirteci iptal edin.',
+      fields: 'Gösterilecek alanlar',
+      fieldPrinter: 'Yazıcı adı',
+      fieldFilename: 'Dosya adı',
+      fieldStatus: 'Durum',
+      fieldProgress: 'İlerleme çubuğu',
+      fieldLayers: 'Katman sayısı',
+      fieldEta: 'Kalan süre ve tahmini bitiş',
+      fieldCamera: 'Kamera görüntüsü',
+      chamberHint: 'Hazne sıcaklığı yalnızca gerçek hazne sensörü olan modellerde görünür: P1 ve A1 anlamsız bir değer bildirdiği için orada gösterilmez.',
+      urlTitle: 'Kaplama URL\'si',
+      open: 'Aç',
+      showPreview: 'Önizlemeyi göster',
+      hidePreview: 'Önizlemeyi gizle',
+      previewTitle: 'Kaplama önizlemesi',
+    },
     status: {
       printing: 'Yazdırılıyor',
       paused: 'Duraklatıldı',
@@ -6480,6 +6545,10 @@ export default {
         title: 'Keşif servisi (port {{port}})',
         fail: 'Bind IP\'sinin {{port}} portunda hiçbir şey dinlemiyor, bu nedenle dilimleyicinin keşif el sıkışması başarısız oluyor.',
       },
+      privileged_ports: {
+        title: 'Ayrıcalıklı bağlantı noktası bağlama',
+        fail: '{{port}} numaralı bağlantı noktası 1024 altındadır ve bu hizmetin onu bağlama izni yoktur; yukarıda hiçbir şeyin dinlememesinin nedeni budur. /etc/systemd/system/bambuddy.service dosyasına AmbientCapabilities=CAP_NET_BIND_SERVICE ekleyip yeniden başlatın veya "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))" komutunu çalıştırın. Docker kullanıyorsanız cap_add: [NET_BIND_SERVICE] ekleyin.',
+      },
       certificate: {
         title: 'TLS sertifikası',
         pass: 'Sertifika hazır. Bambuddy CA sertifikasının (yukarıda) dilimleyicinizin güven deposuna içe aktarıldığından emin olun.',

+ 69 - 0
frontend/src/i18n/locales/uk.ts

@@ -622,6 +622,10 @@ export default {
     },
     // Filaments section
     filaments: "Філаменти",
+    externalSpool: {
+      hide: "Сховати зовнішню котушку",
+      show: "Показати зовнішню котушку",
+    },
     // Camera
     openCameraOverlay: "Відкрити накладання камери",
     openCameraWindow: "Відкрити камеру в новому вікні",
@@ -1207,6 +1211,38 @@ export default {
     // Batch / quantity
     quantity: "Кількість",
     quantityHint: "Буде створено елементів черги: {{count}}",
+    plateQuantityLabel: "Повтори: {{plate}}",
+    plateQuantityTotal_one: "Усього запусків: {{count}}",
+    plateQuantityTotal_other: "Усього запусків: {{count}}",
+    batchOrders: {
+      title: "Пакетні замовлення",
+      emptyTitle: "Пакетних замовлень немає",
+      emptyDescription: "Поставте в чергу файл із кількома столами або кілька копій одного стола — замовлення з’явиться тут разом із прогресом.",
+      filter: {
+        active: "Активні",
+        completed: "Завершені",
+        cancelled: "Скасовані",
+        all: "Усі",
+      },
+      status: {
+        active: "Активне",
+        completed: "Завершене",
+        cancelled: "Скасоване",
+      },
+      noTargets: "Лише групування",
+      noTargetsHint: "Створено до появи цільових кількостей на стіл, тож відстежує поставлені в чергу запуски, а не ціль.",
+      dispatchRemaining: "Поставити в чергу: {{count}}",
+      dispatchPlate: "Поставити решту",
+      dispatched: "Решту запусків для «{{name}}» поставлено в чергу",
+      plateProgress: "Готово {{completed}} з {{target}}",
+      remaining: "Залишилось: {{count}}",
+      failed: "Невдалих: {{count}}",
+      printing: "Друкується: {{count}}",
+      wholeFile: "Увесь файл",
+      due: "Термін: {{date}}",
+      costSoFar: "{{amount}} витрачено",
+      costRemaining: "(ще {{amount}})",
+    },
     activeBatches: "Активні партії",
     batchProgress: "{{completed}} з {{total}} завершено",
     cancelBatch: "Скасувати решту",
@@ -1233,6 +1269,7 @@ export default {
     },
     // Tabs
     tabs: {
+      batches: "Партії",
       queue: "Черга",
       history: "Історія",
       timeline: "Хронологія",
@@ -3325,6 +3362,34 @@ export default {
     eta: "ETA",
     printerIdle: "Принтер неактивний",
     printerOffline: "Принтер не в мережі",
+    builder: {
+      title: "Оформлення трансляції",
+      description: "Складає адресу оформлення трансляції: повноекранне зображення камери з накладеними даними друку — для OBS, настінного екрана або будь-якого джерела-браузера. Виберіть потрібні поля та скопіюйте адресу.",
+      printer: "Принтер",
+      size: "Розмір тексту",
+      sizeSmall: "Малий",
+      sizeMedium: "Середній",
+      sizeLarge: "Великий",
+      fps: "Частота кадрів",
+      fpsHint: "Камери A1 і P1 видають щонайбільше близько 5 кадрів за секунду, хоч би яке значення ви задали.",
+      token: "Токен оформлення трансляції (необов'язково)",
+      tokenHint: "Потрібен лише за увімкненого входу: OBS не має власного сеансу. Створіть його вище з областю «Оформлення трансляції».",
+      tokenWarning: "Ця адреса містить токен: будь-хто, хто її прочитає, побачить трансляцію та назву файлу. Відкличте токен, щоб закрити доступ.",
+      fields: "Поля для показу",
+      fieldPrinter: "Назва принтера",
+      fieldFilename: "Назва файлу",
+      fieldStatus: "Стан",
+      fieldProgress: "Смуга поступу",
+      fieldLayers: "Кількість шарів",
+      fieldEta: "Залишок часу та час завершення",
+      fieldCamera: "Зображення камери",
+      chamberHint: "Температура камери показується лише на моделях зі справжнім датчиком: P1 та A1 повідомляють беззмістовне значення, тому її пропущено.",
+      urlTitle: "Адреса оформлення",
+      open: "Відкрити",
+      showPreview: "Показати попередній перегляд",
+      hidePreview: "Сховати попередній перегляд",
+      previewTitle: "Попередній перегляд оформлення",
+    },
     status: {
       printing: "Друк",
       paused: "Призупинено",
@@ -6584,6 +6649,10 @@ export default {
         title: "Служба виявлення (порт {{port}})",
         fail: "На порту {{port}} IP-адреси прив’язки немає служби, що приймає з’єднання, тому слайсер не може завершити процедуру виявлення.",
       },
+      privileged_ports: {
+        title: 'Прив’язка до привілейованих портів',
+        fail: 'Порт {{port}} нижче 1024, і цій службі не дозволено його займати — тому вище ніщо не слухає. Додайте AmbientCapabilities=CAP_NET_BIND_SERVICE до /etc/systemd/system/bambuddy.service і перезапустіть або виконайте "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))". У Docker додайте cap_add: [NET_BIND_SERVICE].',
+      },
       certificate: {
         title: "Сертифікат TLS",
         pass: "Сертифікат готовий. Переконайтеся, що наведений вище сертифікат центру сертифікації Bambuddy імпортовано до сховища довірених сертифікатів слайсера.",

+ 69 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: '耗材',
+    externalSpool: {
+      hide: '隐藏外部料卷',
+      show: '显示外部料卷',
+    },
     // Camera
     openCameraOverlay: '打开摄像头叠加层',
     openCameraWindow: '在新窗口中打开摄像头',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: '数量',
     quantityHint: '创建 {{count}} 个队列项目',
+    plateQuantityLabel: '{{plate}} 的打印次数',
+    plateQuantityTotal_one: '共 {{count}} 次',
+    plateQuantityTotal_other: '共 {{count}} 次',
+    batchOrders: {
+      title: '批量订单',
+      emptyTitle: '暂无批量订单',
+      emptyDescription: '将多盘文件或同一盘的多份副本加入队列,订单就会连同进度显示在这里。',
+      filter: {
+        active: '进行中',
+        completed: '已完成',
+        cancelled: '已取消',
+        all: '全部',
+      },
+      status: {
+        active: '进行中',
+        completed: '已完成',
+        cancelled: '已取消',
+      },
+      noTargets: '仅分组',
+      noTargetsHint: '创建于按盘目标数量之前,因此跟踪的是已入队的打印,而不是目标数量。',
+      dispatchRemaining: '将剩余 {{count}} 个加入队列',
+      dispatchPlate: '将剩余加入队列',
+      dispatched: '已将 {{name}} 的剩余打印加入队列',
+      plateProgress: '已完成 {{completed}}/{{target}}',
+      remaining: '还差 {{count}} 个',
+      failed: '{{count}} 个失败',
+      printing: '{{count}} 个打印中',
+      wholeFile: '整个文件',
+      due: '截止 {{date}}',
+      costSoFar: '已花费 {{amount}}',
+      costRemaining: '(还需 {{amount}})',
+    },
     activeBatches: '活跃批次',
     batchProgress: '已完成 {{completed}}/{{total}}',
     cancelBatch: '取消剩余',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: '拖动批次',
     },
     tabs: {
+      batches: '批次',
       queue: '队列',
       history: '历史',
       timeline: '时间线',
@@ -3284,6 +3321,34 @@ export default {
     eta: '预计完成时间',
     printerIdle: '打印机空闲',
     printerOffline: '打印机离线',
+    builder: {
+      title: '直播叠加层',
+      description: '生成直播叠加层的网址:全屏摄像头画面上叠加实时打印信息,可用于 OBS、墙面显示屏或任何浏览器源。选择需要的字段并复制网址。',
+      printer: '打印机',
+      size: '文字大小',
+      sizeSmall: '小',
+      sizeMedium: '中',
+      sizeLarge: '大',
+      fps: '帧率',
+      fpsHint: '无论设置多少,A1 和 P1 的摄像头最高约为每秒 5 帧。',
+      token: '直播叠加层令牌(可选)',
+      tokenHint: '仅在启用登录时需要:OBS 没有自己的登录会话。请在上方以直播叠加层范围创建一个。',
+      tokenWarning: '此网址包含令牌:任何能看到它的人都可以观看画面并看到文件名。撤销令牌即可切断访问。',
+      fields: '要显示的字段',
+      fieldPrinter: '打印机名称',
+      fieldFilename: '文件名',
+      fieldStatus: '状态',
+      fieldProgress: '进度条',
+      fieldLayers: '层数',
+      fieldEta: '剩余时间和预计完成时间',
+      fieldCamera: '摄像头画面',
+      chamberHint: '仅在配有真实腔体传感器的机型上显示腔体温度:P1 和 A1 上报的数值没有意义,因此不显示。',
+      urlTitle: '叠加层网址',
+      open: '打开',
+      showPreview: '显示预览',
+      hidePreview: '隐藏预览',
+      previewTitle: '叠加层预览',
+    },
     status: {
       printing: '打印中',
       paused: '已暂停',
@@ -6528,6 +6593,10 @@ export default {
         title: '发现服务(端口 {{port}})',
         fail: '绑定 IP 的端口 {{port}} 上没有任何监听,因此切片软件的发现握手会失败。',
       },
+      privileged_ports: {
+        title: '特权端口绑定',
+        fail: '端口 {{port}} 低于 1024,而此服务没有绑定它的权限,这正是上面没有任何监听的原因。请在 /etc/systemd/system/bambuddy.service 中添加 AmbientCapabilities=CAP_NET_BIND_SERVICE 并重启,或运行 "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))"。使用 Docker 时请添加 cap_add: [NET_BIND_SERVICE]。',
+      },
       certificate: {
         title: 'TLS 证书',
         pass: '证书已就绪。请确保已将 Bambuddy CA 证书(上方)导入切片软件的信任库。',

+ 69 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -618,6 +618,10 @@ export default {
     },
     // Filaments section
     filaments: '耗材',
+    externalSpool: {
+      hide: '隱藏外部料卷',
+      show: '顯示外部料卷',
+    },
     // Camera
     openCameraOverlay: '開啟攝影機疊加層',
     openCameraWindow: '在新視窗中開啟攝影機',
@@ -1198,6 +1202,38 @@ export default {
     // Batch / quantity
     quantity: '數量',
     quantityHint: '建立 {{count}} 個佇列項目',
+    plateQuantityLabel: '{{plate}} 的列印次數',
+    plateQuantityTotal_one: '共 {{count}} 次',
+    plateQuantityTotal_other: '共 {{count}} 次',
+    batchOrders: {
+      title: '批次訂單',
+      emptyTitle: '尚無批次訂單',
+      emptyDescription: '將多盤檔案或同一盤的多份副本加入佇列,訂單就會連同進度顯示在這裡。',
+      filter: {
+        active: '進行中',
+        completed: '已完成',
+        cancelled: '已取消',
+        all: '全部',
+      },
+      status: {
+        active: '進行中',
+        completed: '已完成',
+        cancelled: '已取消',
+      },
+      noTargets: '僅分組',
+      noTargetsHint: '建立於每盤目標數量之前,因此追蹤的是已加入佇列的列印,而不是目標數量。',
+      dispatchRemaining: '將剩餘 {{count}} 個加入佇列',
+      dispatchPlate: '將剩餘加入佇列',
+      dispatched: '已將 {{name}} 的剩餘列印加入佇列',
+      plateProgress: '已完成 {{completed}}/{{target}}',
+      remaining: '還差 {{count}} 個',
+      failed: '{{count}} 個失敗',
+      printing: '{{count}} 個列印中',
+      wholeFile: '整個檔案',
+      due: '截止 {{date}}',
+      costSoFar: '已花費 {{amount}}',
+      costRemaining: '(還需 {{amount}})',
+    },
     activeBatches: '活躍批次',
     batchProgress: '已完成 {{completed}}/{{total}}',
     cancelBatch: '取消剩餘',
@@ -1223,6 +1259,7 @@ export default {
       dragGroup: '拖曳批次',
     },
     tabs: {
+      batches: '批次',
       queue: '佇列',
       history: '歷史',
       timeline: '時間軸',
@@ -3284,6 +3321,34 @@ export default {
     eta: '預計完成時間',
     printerIdle: '印表機空閒',
     printerOffline: '印表機離線',
+    builder: {
+      title: '直播疊加層',
+      description: '產生直播疊加層的網址:全螢幕攝影機畫面上疊加即時列印資訊,可用於 OBS、牆面顯示器或任何瀏覽器來源。選擇需要的欄位並複製網址。',
+      printer: '印表機',
+      size: '文字大小',
+      sizeSmall: '小',
+      sizeMedium: '中',
+      sizeLarge: '大',
+      fps: '影格率',
+      fpsHint: '無論設定多少,A1 與 P1 的攝影機最高約為每秒 5 影格。',
+      token: '直播疊加層權杖(選填)',
+      tokenHint: '僅在啟用登入時需要:OBS 沒有自己的登入工作階段。請在上方以直播疊加層範圍建立一個。',
+      tokenWarning: '此網址包含權杖:任何能看到它的人都可以觀看畫面並看到檔案名稱。撤銷權杖即可中止存取。',
+      fields: '要顯示的欄位',
+      fieldPrinter: '印表機名稱',
+      fieldFilename: '檔案名稱',
+      fieldStatus: '狀態',
+      fieldProgress: '進度列',
+      fieldLayers: '層數',
+      fieldEta: '剩餘時間與預計完成時間',
+      fieldCamera: '攝影機畫面',
+      chamberHint: '僅在具備真實機箱感測器的機型上顯示機箱溫度:P1 與 A1 回報的數值沒有意義,因此不顯示。',
+      urlTitle: '疊加層網址',
+      open: '開啟',
+      showPreview: '顯示預覽',
+      hidePreview: '隱藏預覽',
+      previewTitle: '疊加層預覽',
+    },
     status: {
       printing: '列印中',
       paused: '已暫停',
@@ -6528,6 +6593,10 @@ export default {
         title: '探索服務(連接埠 {{port}})',
         fail: '繫結 IP 的連接埠 {{port}} 上沒有任何監聽,因此切片軟體的探索交握會失敗。',
       },
+      privileged_ports: {
+        title: '特權連接埠繫結',
+        fail: '連接埠 {{port}} 低於 1024,而此服務沒有繫結它的權限,這正是上面沒有任何項目在接聽的原因。請在 /etc/systemd/system/bambuddy.service 中加入 AmbientCapabilities=CAP_NET_BIND_SERVICE 並重新啟動,或執行 "sudo setcap cap_net_bind_service=+ep $(readlink -f $(which python3))"。使用 Docker 時請加入 cap_add: [NET_BIND_SERVICE]。',
+      },
       certificate: {
         title: 'TLS 憑證',
         pass: '憑證已就緒。請確保已將 Bambuddy CA 憑證(上方)匯入切片軟體的信任庫。',

Разница между файлами не показана из-за своего большого размера
+ 215 - 125
frontend/src/pages/PrintersPage.tsx


+ 20 - 5
frontend/src/pages/QueuePage.tsx

@@ -77,6 +77,7 @@ import { useAuth } from '../contexts/AuthContext';
 import { QueueStatsBar } from '../components/QueueStatsBar';
 import { CompactHistoryRow } from '../components/CompactHistoryRow';
 import { QueueTimelineView } from '../components/QueueTimelineView';
+import { BatchOrdersView } from '../components/BatchOrdersView';
 
 function formatWeight(g: number, useKg = false): string {
   if (useKg && g >= 1000) return `${(g / 1000).toFixed(1)}kg`;
@@ -1444,16 +1445,16 @@ export function QueuePage() {
   // History tab renders unconditionally so this no longer drives the UI.
   // Tabbed page structure: Active queue stays as the main view; History
   // and Timeline split off. Persists per-user via localStorage.
-  const [activeTab, setActiveTab] = useState<'queue' | 'history' | 'timeline' | 'pipelines'>(() => {
+  const [activeTab, setActiveTab] = useState<'queue' | 'batches' | 'history' | 'timeline' | 'pipelines'>(() => {
     // URL deep-link wins so the legacy /pipelines/runs redirect lands on the
     // right tab. localStorage holds the per-user last-selected fallback.
     const search = new URLSearchParams(window.location.search);
     const url = search.get('tab');
-    if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue') {
+    if (url === 'pipelines' || url === 'history' || url === 'timeline' || url === 'queue' || url === 'batches') {
       return url;
     }
     const saved = localStorage.getItem('queue.activeTab');
-    if (saved === 'history' || saved === 'timeline' || saved === 'pipelines') return saved;
+    if (saved === 'history' || saved === 'timeline' || saved === 'pipelines' || saved === 'batches') return saved;
     return 'queue';
   });
   // Active-tab layout toggle. "position" = today's flat list; "printer"
@@ -1528,6 +1529,17 @@ export function QueuePage() {
 
   const timeFormat: TimeFormat = settings?.time_format || 'system';
 
+  // Badge count for the Batches tab (#342). Deliberately its own query rather
+  // than derived from the queue: an order whose runs have all finished has no
+  // queue rows left, and those are precisely the orders the tab exists to
+  // surface. Shares the ['batches'] key with the tab itself, so dispatching or
+  // cancelling refreshes both.
+  const { data: activeBatches } = useQuery({
+    queryKey: ['batches', 'active'],
+    queryFn: () => api.getBatches('active'),
+  });
+  const activeBatchCount = activeBatches?.length ?? 0;
+
   const { data: queue, isLoading } = useQuery({
     queryKey: ['queue', filterPrinter, filterStatus],
     queryFn: () => api.getQueue(filterPrinter || undefined, filterStatus || undefined),
@@ -2313,6 +2325,7 @@ export function QueuePage() {
       <div className="flex gap-1 border-b border-bambu-dark-tertiary mb-6 overflow-x-auto">
         {([
           { id: 'queue' as const, label: t('queue.tabs.queue'), icon: Clock, count: pendingItems.length + activeItems.length },
+          { id: 'batches' as const, label: t('queue.tabs.batches'), icon: Package, count: activeBatchCount },
           { id: 'history' as const, label: t('queue.tabs.history'), icon: ListOrdered, count: historyItems.length },
           { id: 'timeline' as const, label: t('queue.tabs.timeline'), icon: GanttChart, count: null as number | null },
           // Slicer Pipelines dashboard (#1425 PR C). Lives here instead of
@@ -2343,7 +2356,7 @@ export function QueuePage() {
       </div>
 
       {/* Summary Stats — about the print queue, not pipelines. */}
-      {activeTab !== 'pipelines' && <QueueStatsBar
+      {activeTab !== 'pipelines' && activeTab !== 'batches' && <QueueStatsBar
         activeCount={activeItems.length}
         pendingCount={pendingItems.length}
         totalTime={totalQueueTime}
@@ -2392,7 +2405,7 @@ export function QueuePage() {
       {/* Filters — about the print queue items (printer / status / location).
           The Pipelines tab has its own pipeline + status filters inside the
           dashboard, so this row is hidden when that tab is active. */}
-      {activeTab !== 'pipelines' && (
+      {activeTab !== 'pipelines' && activeTab !== 'batches' && (
       <div className="flex flex-wrap items-center gap-2 sm:gap-4 mb-6">
         <select
           className="px-2 sm:px-3 py-2 text-sm sm:text-base bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none min-w-0 flex-1 sm:flex-none"
@@ -2505,6 +2518,8 @@ export function QueuePage() {
           dashboard renders even when the regular queue is empty. */}
       {activeTab === 'pipelines' ? (
         <PipelineRunsView />
+      ) : activeTab === 'batches' ? (
+        <BatchOrdersView hasPermission={hasPermission} t={t} />
       ) : isLoading ? (
         <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
       ) : queue?.length === 0 ? (

+ 17 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode, Heart, Briefcase, Workflow, UploadCloud, MonitorPlay } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -16,6 +16,7 @@ import { Card, CardContent, CardDensityProvider, CardHeader } from '../component
 import { SlicerBundlesPanel } from '../components/SlicerBundlesPanel';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
+import { StreamOverlayBuilder } from '../components/StreamOverlayBuilder';
 import { Collapsible } from '../components/Collapsible';
 import { CopyButton } from '../components/CopyButton';
 import { Button } from '../components/Button';
@@ -4251,6 +4252,21 @@ export function SettingsPage() {
                 <CameraTokensSection />
               </CardContent>
             </Card>
+
+            {/* Streaming-overlay URL builder (#1422). Sits under the camera
+                tokens it usually needs — an overlay for a login-enabled
+                deployment is a token plus a URL, and both are made here. */}
+            <Card className="mt-6">
+              <CardHeader>
+                <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-stream-overlay">
+                  <MonitorPlay className="w-4 h-4 text-bambu-green" />
+                  {t('streamOverlay.builder.title', 'Streaming Overlay')}
+                </h3>
+              </CardHeader>
+              <CardContent>
+                <StreamOverlayBuilder />
+              </CardContent>
+            </Card>
           </div>
 
           {/* Right Column - API Browser. Hidden from users without

+ 129 - 1
frontend/src/pages/StreamOverlayPage.tsx

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
 import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
-import { Layers, Clock, Timer, Printer } from 'lucide-react';
+import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 
@@ -20,6 +20,9 @@ interface OverlayConfig {
   showFilename: boolean;
   showStatus: boolean;
   showPrinter: boolean;
+  showNozzle: boolean;
+  showBed: boolean;
+  showChamber: boolean;
 }
 
 function formatPrintName(name: string | null, gcodeFile: string | null | undefined, t: (key: string, fallback: string, opts?: Record<string, unknown>) => string): string {
@@ -33,6 +36,9 @@ function formatPrintName(name: string | null, gcodeFile: string | null | undefin
 }
 
 function parseConfig(params: URLSearchParams): OverlayConfig {
+  // The default set is deliberately unchanged by #1422: temperatures are opt-in,
+  // so every overlay URL already pasted into an OBS scene keeps looking the same
+  // after upgrading.
   const show = params.get('show')?.split(',') || ['progress', 'layers', 'eta', 'filename', 'status'];
 
   // Parse FPS (default 15, max 30, min 1)
@@ -53,6 +59,9 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
     showFilename: show.includes('filename'),
     showStatus: show.includes('status'),
     showPrinter: show.includes('printer'),
+    showNozzle: show.includes('nozzle'),
+    showBed: show.includes('bed'),
+    showChamber: show.includes('chamber'),
   };
 }
 
@@ -71,6 +80,46 @@ function getStatusText(status: { state: string | null; stg_cur_name?: string | n
   }
 }
 
+// Reads one reading out of either status shape. The kiosk feed types
+// temperatures as Record<string, number>; the logged-in PrinterStatus types it
+// as a named object that also carries `*_heating` booleans. Narrowing here lets
+// one render path serve both without casting.
+function readTemp(temps: Record<string, unknown>, key: string): number | null {
+  const value = temps[key];
+  return typeof value === 'number' ? value : null;
+}
+
+interface TempReadingProps {
+  icon: React.ReactNode;
+  label: string;
+  current: number;
+  target: number | null;
+  sizes: ReturnType<typeof getSizeClasses>;
+}
+
+// One "Nozzle 220°C" reading. The target is appended only while it is set and
+// still differs from the current value, so a hotend that has reached
+// temperature reads "220°C" for the rest of the print instead of the noisier
+// "220 / 220°C".
+function TempReading({ icon, label, current, target, sizes }: TempReadingProps) {
+  const heating = target != null && target > 0 && Math.round(target) !== Math.round(current);
+  return (
+    <div className={`flex items-center ${sizes.gap} text-white/70`}>
+      {icon}
+      <span className={sizes.text}>
+        <span className="mr-1">{label}</span>
+        <span className="text-white">{Math.round(current)}°C</span>
+        {heating && (
+          <>
+            <span className="mx-1">/</span>
+            <span>{Math.round(target)}°C</span>
+          </>
+        )}
+      </span>
+    </div>
+  );
+}
+
 function getSizeClasses(size: OverlaySize) {
   switch (size) {
     case 'small':
@@ -258,6 +307,64 @@ export function StreamOverlayPage() {
 
   const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
   const progress = status.progress || 0;
+
+  // Temperature readings the URL asked for, in a fixed order, skipping any the
+  // printer doesn't report. Labels reuse printers.heaterHistory.* so the naming
+  // matches the heater chart rather than inventing a second vocabulary.
+  const temps: Record<string, unknown> = status.temperatures ?? {};
+  const tempReadings: {
+    key: string;
+    icon: React.ReactNode;
+    label: string;
+    current: number;
+    target: number | null;
+  }[] = [];
+  if (config.showNozzle) {
+    const nozzle = readTemp(temps, 'nozzle');
+    const nozzle2 = readTemp(temps, 'nozzle_2');
+    if (nozzle != null) {
+      tempReadings.push({
+        key: 'nozzle',
+        icon: <Flame className={sizes.icon} />,
+        label: t('printers.heaterHistory.nozzle', 'Nozzle'),
+        current: nozzle,
+        target: readTemp(temps, 'nozzle_target'),
+      });
+    }
+    if (nozzle2 != null) {
+      tempReadings.push({
+        key: 'nozzle_2',
+        icon: <Flame className={sizes.icon} />,
+        label: t('printers.heaterHistory.nozzle2', 'Nozzle 2'),
+        current: nozzle2,
+        target: readTemp(temps, 'nozzle_2_target'),
+      });
+    }
+  }
+  if (config.showBed) {
+    const bed = readTemp(temps, 'bed');
+    if (bed != null) {
+      tempReadings.push({
+        key: 'bed',
+        icon: <Square className={sizes.icon} />,
+        label: t('printers.heaterHistory.bed', 'Bed'),
+        current: bed,
+        target: readTemp(temps, 'bed_target'),
+      });
+    }
+  }
+  if (config.showChamber) {
+    const chamber = readTemp(temps, 'chamber');
+    if (chamber != null) {
+      tempReadings.push({
+        key: 'chamber',
+        icon: <Box className={sizes.icon} />,
+        label: t('printers.heaterHistory.chamber', 'Chamber'),
+        current: chamber,
+        target: readTemp(temps, 'chamber_target'),
+      });
+    }
+  }
   // Append the kiosk token directly rather than leaning on withStreamToken's
   // module cache — the cache is populated by an effect and would miss the first
   // render (a 401 flash before the retry). The logged-in path keeps the cache.
@@ -377,6 +484,27 @@ export function StreamOverlayPage() {
               {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
             </div>
           )}
+
+          {/* Temperatures (#1422). Rendered whether or not a print is running —
+              a preheating or cooling printer is exactly when these are worth
+              watching. Each reading appears only when the printer reports it,
+              so a single-nozzle machine shows one nozzle and a model without a
+              chamber sensor shows no chamber row even if `chamber` is in
+              ?show= (the backend omits the reading entirely for those). */}
+          {tempReadings.length > 0 && (
+            <div className={`flex items-center ${sizes.gap} flex-wrap mt-2`}>
+              {tempReadings.map((reading) => (
+                <TempReading
+                  key={reading.key}
+                  icon={reading.icon}
+                  label={reading.label}
+                  current={reading.current}
+                  target={reading.target}
+                  sizes={sizes}
+                />
+              ))}
+            </div>
+          )}
         </div>
       </div>
     </div>

+ 56 - 0
frontend/src/utils/printerCardPrefs.ts

@@ -0,0 +1,56 @@
+/**
+ * Per-printer view preferences for the printer card.
+ *
+ * These are browser-local, like every other printer-page view preference
+ * (`printerCardSize`, `hideDisconnectedPrinters`, `printerCollapsedSections`).
+ * They describe how one person wants their own screen to look, not anything
+ * about the printer, so they deliberately do not go to the backend.
+ *
+ * Keyed by printer id rather than held as a single global flag: the toggle
+ * lives on the card itself, so hiding the external spool on one printer must
+ * not silently rearrange every other card in a fleet.
+ */
+
+const HIDDEN_EXTERNAL_SPOOLS_KEY = 'printerHiddenExternalSpools';
+
+function readHiddenExternalSpools(): Record<string, boolean> {
+  try {
+    const saved = localStorage.getItem(HIDDEN_EXTERNAL_SPOOLS_KEY);
+    if (!saved) return {};
+    const parsed: unknown = JSON.parse(saved);
+    // Anything that isn't a plain object (an older format, or a value another
+    // tab mangled) is discarded rather than indexed into.
+    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
+    return parsed as Record<string, boolean>;
+  } catch {
+    // Malformed JSON, or localStorage unavailable (private mode / blocked
+    // cookies). Showing the external spool is the safe default either way.
+    return {};
+  }
+}
+
+/** Whether this printer's external spool should be left out of the card. */
+export function isExternalSpoolHidden(printerId: number): boolean {
+  return readHiddenExternalSpools()[String(printerId)] === true;
+}
+
+/**
+ * Persist the toggle. Re-reads before writing so two cards toggled in the same
+ * session can't clobber each other's entry, and drops the key entirely when
+ * shown again so the stored object doesn't accumulate `false` for every printer
+ * the user ever toggled twice.
+ */
+export function setExternalSpoolHidden(printerId: number, hidden: boolean): void {
+  const next = readHiddenExternalSpools();
+  if (hidden) {
+    next[String(printerId)] = true;
+  } else {
+    delete next[String(printerId)];
+  }
+  try {
+    localStorage.setItem(HIDDEN_EXTERNAL_SPOOLS_KEY, JSON.stringify(next));
+  } catch {
+    // Quota exceeded or private mode — the toggle still applies for this
+    // session, it just won't survive a reload.
+  }
+}

+ 7 - 0
spoolbuddy/install/install.sh

@@ -808,6 +808,13 @@ TimeoutStopSec=30
 StandardOutput=journal
 StandardError=journal
 
+# Allow binding to privileged ports (322 RTSP, 990 FTPS) for Virtual Printer
+# mode. The Bambuddy-only installer has had this since #757; this unit did not,
+# so a full-mode install produced a virtual printer whose sockets never opened
+# (#2549). Compatible with NoNewPrivileges below — systemd raises the ambient
+# set at exec, which is not the escalation that setting forbids.
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+
 NoNewPrivileges=true
 PrivateTmp=true
 ProtectSystem=strict

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
static/assets/index-B3jj6-fz.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-B67xFyee.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/index-GBTQ2eaA.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-TuCPjeGc.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-GBTQ2eaA.css">
+    <script type="module" crossorigin src="/assets/index-B67xFyee.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-B3jj6-fz.css">
   </head>
   <body>
     <div id="root"></div>

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