瀏覽代碼

feat(printers): time to the next slicer pause, and a heads-up (issue #2984)

- Read the pauses (M400 U1) of the printing plate's G-code from the
  archived 3MF, with the slicer's time left at each (last M73 R), at
  every print start that binds an archive, on 3MF recovery and after
  a restart; streamed line by line
- next_pause {layer, minutes} in REST and WebSocket status while
  RUNNING; minutes only at Standard speed, the slicer's times being
  for Standard
- Printer card shows "20m to pause · 20:27" next to the ETA
- New "Pause Coming Up" notification event and template, sent once per
  pause; warned pauses kept on the archive so a restart does not send
  it again; a resumed print skips the pauses it has passed
- New setting pause_warning_minutes (default 5, 1-120)
- Requested by @apizz.
maziggy 9 小時之前
父節點
當前提交
8901ff849a
共有 88 個文件被更改,包括 1903 次插入 和 15 次删除
  1. 4 0
      CHANGELOG.md
  2. 1 0
      backend/app/api/routes/notification_templates.py
  3. 3 0
      backend/app/api/routes/notifications.py
  4. 3 0
      backend/app/api/routes/printers.py
  5. 1 0
      backend/app/api/routes/settings.py
  6. 16 0
      backend/app/core/database.py
  7. 88 3
      backend/app/main.py
  8. 3 0
      backend/app/models/notification.py
  9. 6 0
      backend/app/models/notification_template.py
  10. 6 0
      backend/app/schemas/notification.py
  11. 20 0
      backend/app/schemas/notification_template.py
  12. 10 0
      backend/app/schemas/printer.py
  13. 6 0
      backend/app/schemas/settings.py
  14. 18 0
      backend/app/services/bambu_mqtt.py
  15. 38 0
      backend/app/services/notification_service.py
  16. 164 0
      backend/app/services/pause_eta.py
  17. 3 0
      backend/app/services/printer_manager.py
  18. 90 9
      backend/app/utils/threemf_tools.py
  19. 122 0
      backend/tests/integration/test_pause_load_paths_2984.py
  20. 94 0
      backend/tests/integration/test_pause_warning_2984.py
  21. 666 0
      backend/tests/unit/test_pause_eta_2984.py
  22. 142 0
      frontend/src/__tests__/components/NotificationPauseUpcoming.test.tsx
  23. 91 0
      frontend/src/__tests__/pages/PrintersPageNextPause.test.tsx
  24. 97 0
      frontend/src/__tests__/pages/SettingsPagePauseWarning.test.tsx
  25. 8 0
      frontend/src/api/client.ts
  26. 10 0
      frontend/src/components/AddNotificationModal.tsx
  27. 14 0
      frontend/src/components/NotificationProviderCard.tsx
  28. 8 0
      frontend/src/i18n/locales/de.ts
  29. 8 0
      frontend/src/i18n/locales/en.ts
  30. 8 0
      frontend/src/i18n/locales/es.ts
  31. 8 0
      frontend/src/i18n/locales/fr.ts
  32. 8 0
      frontend/src/i18n/locales/it.ts
  33. 8 0
      frontend/src/i18n/locales/ja.ts
  34. 8 0
      frontend/src/i18n/locales/ko.ts
  35. 8 0
      frontend/src/i18n/locales/nl.ts
  36. 8 0
      frontend/src/i18n/locales/pt-BR.ts
  37. 8 0
      frontend/src/i18n/locales/ru.ts
  38. 8 0
      frontend/src/i18n/locales/sv.ts
  39. 8 0
      frontend/src/i18n/locales/tr.ts
  40. 8 0
      frontend/src/i18n/locales/uk.ts
  41. 8 0
      frontend/src/i18n/locales/zh-CN.ts
  42. 8 0
      frontend/src/i18n/locales/zh-TW.ts
  43. 19 0
      frontend/src/pages/PrintersPage.tsx
  44. 37 0
      frontend/src/pages/SettingsPage.tsx
  45. 0 0
      static/assets/ArchivesPage-DK8Jd5wQ.js
  46. 0 0
      static/assets/CamWallPage-BUC3zHkP.js
  47. 0 0
      static/assets/CameraPage-CbtcgUxC.js
  48. 0 0
      static/assets/FileManagerPage-BxfQtfJp.js
  49. 0 0
      static/assets/InventoryPage-QEg8BksK.js
  50. 0 0
      static/assets/Layout-DfNbRVI6.js
  51. 0 0
      static/assets/Layout-_oJ-ykLc.js
  52. 0 0
      static/assets/ModelSourcesPage-VPvkCpjn.js
  53. 0 0
      static/assets/PhotoGalleryModal-COMGC-_m.js
  54. 0 0
      static/assets/PrintersPage-CCCkF3oJ.js
  55. 0 0
      static/assets/SettingsPage-3-NATkhb.js
  56. 0 0
      static/assets/SettingsPage-Cw_y12mU.js
  57. 0 0
      static/assets/SliceModal-CWxvqsV3.js
  58. 1 1
      static/assets/SpoolBuddySettingsPage-BoTM3ff6.js
  59. 0 0
      static/assets/de-B8d4u1gC.js
  60. 0 0
      static/assets/de-BikatwOZ.js
  61. 0 0
      static/assets/es-CR0SSq8S.js
  62. 0 0
      static/assets/es-L-2yOMsc.js
  63. 0 0
      static/assets/fr-BOnzRK4I.js
  64. 0 0
      static/assets/fr-DgNCXgNC.js
  65. 0 0
      static/assets/index-C8_xNeLP.js
  66. 0 0
      static/assets/it-BFxneBzB.js
  67. 0 0
      static/assets/it-qEgZ1gfe.js
  68. 0 0
      static/assets/ja-Cl5BNUjU.js
  69. 0 0
      static/assets/ko-CGa-YoWG.js
  70. 0 0
      static/assets/ko-CyFKZwiF.js
  71. 0 0
      static/assets/locationSensorDefaults-CkYZEEPm.js
  72. 0 0
      static/assets/nl-BdL4-Fiw.js
  73. 0 0
      static/assets/nl-DfaOqiW3.js
  74. 0 0
      static/assets/pt-BR-DRmpSEwF.js
  75. 0 0
      static/assets/pt-BR-vwQWjDRH.js
  76. 0 0
      static/assets/ru-CF-c9YSH.js
  77. 0 0
      static/assets/ru-DDjN_NDo.js
  78. 0 0
      static/assets/sv-DGaQLCku.js
  79. 0 0
      static/assets/sv-DoYSwBHG.js
  80. 0 0
      static/assets/tr-BzDsH2JK.js
  81. 0 0
      static/assets/tr-DGxBjCtq.js
  82. 0 0
      static/assets/uk-CclO3Zoh.js
  83. 0 0
      static/assets/uk-D55DRuhE.js
  84. 0 0
      static/assets/zh-CN-BdhRNjdm.js
  85. 0 0
      static/assets/zh-CN-Duqvqkcg.js
  86. 0 0
      static/assets/zh-TW-B6LTXDxh.js
  87. 0 0
      static/assets/zh-TW-DPD4-jvL.js
  88. 2 2
      static/index.html

+ 4 - 0
CHANGELOG.md

@@ -5,6 +5,10 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.0b1] - Unreleased
 
 ### Added
+- **Time to the next pause, and a heads-up before it (#2984, requested by @apizz)** — A pause added in the slicer, for an NFC chip, a magnet or threaded inserts, came without warning: the only way to be there for it was to look up its layer in the slicer and keep checking the layer count.
+  - **On the printer card:** next to the ETA, in blue, the time until the next pause and its clock time, e.g. "20m to pause · 20:27". Hover it for the layer. With several pauses it counts down to each in turn. Sizes M and up; the one-line size S card has no room for it. In a speed mode other than Standard it shows the layer only, as the slicer's times are for Standard speed.
+  - **Pause Coming Up notification:** a new event for notification channels, sent once per pause, 5 minutes before by default (at Standard speed). Set the minutes under Settings > Notifications > **Pause Heads-Up** (1-120). Off on every channel until turned on, with its own template.
+  - **Where it comes from:** the pauses are read from the print's G-code in its archived 3MF, written there by Bambu Studio and OrcaSlicer. Prints without an archived 3MF show none. Wiki updated.
 - **Printer cameras from any page (#2737, requested by @apizz)** — An embedded camera overlay belonged to the Printers page: it disappeared on every other page and came back only on returning there, so a quick look at a print meant leaving the page you were on.
   - **On every page:** open overlays now stay open as you move around Bambuddy, and still reopen after a reload.
   - **Cameras button:** a camera icon at the end of the sidebar's **Printers** row (on hover when the sidebar is collapsed), in the header on narrow screens, or the **C** key, lists the printers you can see and opens one as an overlay. A camera already open is ticked and closes from the same list. Users without `camera:view` and `printers:read` don't see it, or any overlay.

+ 1 - 0
backend/app/api/routes/notification_templates.py

@@ -48,6 +48,7 @@ EVENT_NAMES = {
     "filament_low": "Filament Low",
     "maintenance_due": "Maintenance Due",
     "first_layer_complete": "First Layer Complete",
+    "print_pause_upcoming": "Pause Coming Up",
     "test": "Test Notification",
     # Queue notifications
     "queue_job_added": "Queue Job Added",

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

@@ -155,6 +155,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_bed_cooled": provider.on_bed_cooled,
         # First layer complete
         "on_first_layer_complete": provider.on_first_layer_complete,
+        # A slicer pause coming up (#2984)
+        "on_print_pause_upcoming": bool(provider.on_print_pause_upcoming),
         # Messages from connected apps
         "on_app_message": bool(provider.on_app_message),
         # Inventory stock alerts. Absent here, the toggles above always read
@@ -309,6 +311,7 @@ async def create_notification_provider(
         on_bed_cooled=provider_data.on_bed_cooled,
         # First layer complete
         on_first_layer_complete=provider_data.on_first_layer_complete,
+        on_print_pause_upcoming=provider_data.on_print_pause_upcoming,
         on_app_message=provider_data.on_app_message,
         # Inventory stock alerts
         on_stock_reorder_alert=provider_data.on_stock_reorder_alert,

+ 3 - 0
backend/app/api/routes/printers.py

@@ -38,6 +38,7 @@ from backend.app.schemas.printer import (
     ExtruderSlotResponse,
     FilaSwitchResponse,
     HmsActionBody,
+    NextPause,
     NozzleInfoResponse,
     NozzleRackSlot,
     PrinterCreate,
@@ -65,6 +66,7 @@ from backend.app.services.bambu_ftp import (
     list_files_result_async,
 )
 from backend.app.services.hms_actions import HMSAction
+from backend.app.services.pause_eta import next_pause
 from backend.app.services.print_storage import ftp_probe_paths, print_file_reachable_over_ftp
 from backend.app.services.printer_diagnostic import run_connection_diagnostic
 from backend.app.services.printer_file_cache import printer_file_cache
@@ -857,6 +859,7 @@ async def _printer_status(db: AsyncSession, printer: Printer) -> PrinterStatus:
         mc_print_sub_stage=state.mc_print_sub_stage,
         last_ams_update=state.last_ams_update,
         printable_objects_count=len(state.printable_objects),
+        next_pause=NextPause(**upcoming) if (upcoming := next_pause(state)) else None,
         cooling_fan_speed=state.cooling_fan_speed,
         big_fan1_speed=state.big_fan1_speed,
         big_fan2_speed=state.big_fan2_speed,

+ 1 - 0
backend/app/api/routes/settings.py

@@ -261,6 +261,7 @@ _INT_SETTING_KEYS = frozenset(
         "queue_keep_warm_max_minutes",
         "queue_max_concurrent_uploads",
         "ambient_drying_sustained_minutes",
+        "pause_warning_minutes",
     }
 )
 

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

@@ -5873,6 +5873,21 @@ async def _run_migrations(conn):
     # default -- identical DDL on SQLite and Postgres.
     await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN duration_unmeasured BOOLEAN")
 
+    # Migration: heads-up before a pause the slicer put into the print (#2984).
+    # Off by default, so no channel starts sending it on upgrade. The backfill
+    # covers a table create_all() already gave the column (the ALTER is then
+    # swallowed as a duplicate and existing rows keep NULL).
+    await _safe_execute(
+        conn, "ALTER TABLE notification_providers ADD COLUMN on_print_pause_upcoming BOOLEAN DEFAULT FALSE"
+    )
+    async with conn.begin_nested():
+        await conn.execute(
+            text(
+                "UPDATE notification_providers SET on_print_pause_upcoming = :off WHERE on_print_pause_upcoming IS NULL"
+            ),
+            {"off": False},
+        )
+
     await _migrate_auth_datetimes_to_timestamp(conn)
     await _migrate_add_missing_set_null_fks(conn)
     await _migrate_wallet_transactions_keep_deleted_users(conn)
@@ -5925,6 +5940,7 @@ _PG_COLUMNS_SET_FROM_MODEL = (
     ("notification_providers", "on_location_ha_sensor_alert"),
     ("notification_providers", "on_plate_clear_required"),
     ("notification_providers", "on_print_confirm_request"),
+    ("notification_providers", "on_print_pause_upcoming"),
     ("notification_providers", "on_stock_break_alert"),
     ("notification_providers", "on_stock_reorder_alert"),
     ("notification_providers", "telegram_verdict_mode"),

+ 88 - 3
backend/app/main.py

@@ -104,7 +104,7 @@ from backend.app.core.database import async_session, engine, init_db
 from backend.app.core.static_assets import AssetStaticFiles
 from backend.app.core.tasks import spawn_background_task
 from backend.app.core.websocket import ws_manager
-from backend.app.services import kprofile_drift, print_dispatch_context, slot_unlink_grace
+from backend.app.services import kprofile_drift, pause_eta, print_dispatch_context, slot_unlink_grace
 from backend.app.services.ams_assignment_lock import get_ams_assignment_lock
 from backend.app.services.archive import ArchiveService, peek_plate_index_in_3mf, swap_plate_suffix
 from backend.app.services.archive_cost_estimate import (
@@ -1894,6 +1894,10 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
         notify_live_activities.observe(printer_id, state)
     except Exception:
         logging.getLogger(__name__).exception("Notify Live Activity status hook failed for printer %s", printer_id)
+    try:
+        pause_eta.observe(printer_id, state)
+    except Exception:
+        logging.getLogger(__name__).exception("Pause heads-up check failed for printer %s", printer_id)
     # Connected-edge reconciliation (#1542 follow-up). When the printer
     # transitions disconnected → connected — which covers both Bambuddy
     # startup (no prior connection) and a mid-session MQTT reconnect — fire
@@ -3808,6 +3812,65 @@ def _load_objects_from_archive(archive, printer_id: int, logger) -> None:
         logger.debug("Failed to extract printable objects from archive: %s", e)
 
 
+def _load_pauses_from_3mf(threemf_path, printer_id: int, *, warned_layers=(), passed_through_layer: int = 0) -> None:
+    """Read the running print's slicer pauses from its 3MF, in the background (#2984).
+
+    In a thread: a plate's G-code runs to tens of MB, and the print-start
+    handler must not wait for it. Kept only while the printer is still on
+    the print they were read for: a new print replaces the list.
+    ``warned_layers``: pauses this print was already warned about, kept on its
+    archive, so a restart does not send the heads-up again.
+    ``passed_through_layer``: for a print resumed after a restart, the layer it
+    is on. Its pauses up to there are behind it, though the printer's layer
+    reads 0 until it reports the next one.
+    """
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        return
+    state = client.state
+    read_for = state.pause_points
+    try:
+        plate_id = resolve_plate_id(state)
+    except Exception:
+        # Never at the cost of the print-start handler this runs inside.
+        logging.getLogger(__name__).warning("[Printer %s] Could not tell the plate to read pauses for", printer_id)
+        return
+
+    async def _load() -> None:
+        from backend.app.utils.threemf_tools import extract_pause_points_from_3mf
+
+        points = await asyncio.to_thread(extract_pause_points_from_3mf, Path(threemf_path), plate_id)
+        if state.pause_points is not read_for:
+            return
+        state.pause_points = [p for p in points if p.layer > passed_through_layer]
+        state.pause_warned_layers = set(state.pause_warned_layers) | set(warned_layers)
+        if points:
+            logging.getLogger(__name__).info(
+                "Loaded %s slicer pause(s) for printer %s at layer(s) %s",
+                len(points),
+                printer_id,
+                [p.layer for p in points],
+            )
+
+    spawn_background_task(_load(), name=f"load-pauses-{printer_id}")
+
+
+def _load_pauses_from_resumed_archive(archive, printer_id: int) -> None:
+    """Pauses for a print start taken as the resumption of a running print.
+
+    A restart while the print was paused makes its resume look like a new
+    print (#2984): the pauses warned about and passed are kept from before.
+    """
+    client = printer_manager.get_client(printer_id)
+    state = client.state if client else None
+    _load_pauses_from_3mf(
+        app_settings.base_dir / archive.file_path,
+        printer_id,
+        warned_layers=pause_eta.warned_layers_of(archive, state) if state is not None else (),
+        passed_through_layer=getattr(client, "layer_before_new_print", 0) if client else 0,
+    )
+
+
 async def _restore_printable_objects(printer_id: int, state, db, logger) -> None:
     """Put the skip-objects list back after a restart mid-print.
 
@@ -3831,12 +3894,20 @@ async def _restore_printable_objects(printer_id: int, state, db, logger) -> None
     reload path on ``GET /print/objects`` covers that case on demand.
     """
     client = printer_manager.get_client(printer_id)
-    if client is None or client.state.printable_objects:
+    if client is None or (client.state.printable_objects and client.state.pause_points):
         return
 
     archive = await running_print_archive(db, printer_id, state, printing_only=True, by_name=False)
     if archive is not None and archive.file_path:
-        _load_objects_from_archive(archive, printer_id, logger)
+        if not client.state.printable_objects:
+            _load_objects_from_archive(archive, printer_id, logger)
+        # The slicer's pauses are in-memory only as well (#2984).
+        if not client.state.pause_points:
+            _load_pauses_from_3mf(
+                app_settings.base_dir / archive.file_path,
+                printer_id,
+                warned_layers=pause_eta.warned_layers_of(archive, state),
+            )
 
 
 # Retry ladder for a fallback archive created while the printer's FTPS cool-off
@@ -4014,6 +4085,9 @@ async def _recover_fallback_archive_locked(
             logger.warning(
                 "[RECOVER] Archive %s: could not add the 3MF to Spoolman's usage tracking", archive_id, exc_info=True
             )
+        if price and recovered.status == "printing" and recovered.file_path:
+            # Print start had no 3MF to read the slicer's pauses from (#2984).
+            _load_pauses_from_3mf(app_settings.base_dir / recovered.file_path, printer_id)
         if price and recovered.status == "printing":
             # Print start priced it from the default rate, having no 3MF to
             # estimate from; now it has one. An ended print is left alone: its
@@ -5140,6 +5214,8 @@ async def on_print_start(printer_id: int, data: dict):
 
                 # Extract printable objects from the archived 3MF file
                 _load_objects_from_archive(archive, printer_id, logger)
+                if archive.file_path:
+                    _load_pauses_from_3mf(app_settings.base_dir / archive.file_path, printer_id)
 
                 # Store Spoolman tracking data for per-filament usage reporting
                 try:
@@ -5253,6 +5329,8 @@ async def on_print_start(printer_id: int, data: dict):
                     }
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                 _load_objects_from_archive(existing_archive, printer_id, logger)
+                if existing_archive.file_path:
+                    _load_pauses_from_resumed_archive(existing_archive, printer_id)
                 return
 
             # Name-match only (no subtask_id to anchor on): decide resume vs.
@@ -5347,6 +5425,8 @@ async def on_print_start(printer_id: int, data: dict):
                     await _send_print_start_notification(printer_id, data, archive_data, logger)
                 # Extract printable objects from the archived 3MF file
                 _load_objects_from_archive(existing_archive, printer_id, logger)
+                if existing_archive.file_path:
+                    _load_pauses_from_resumed_archive(existing_archive, printer_id)
                 return
 
         # Build list of possible 3MF filenames to try
@@ -6210,6 +6290,11 @@ async def on_print_start(printer_id: int, data: dict):
                 except Exception as e:
                     logger.debug("Failed to extract printable objects: %s", e)
 
+                # From the archived copy: the downloaded one may be removed
+                # below while the pauses are still being read.
+                if archive is not None and archive.file_path:
+                    _load_pauses_from_3mf(app_settings.base_dir / archive.file_path, printer_id)
+
                 # Store Spoolman tracking data for per-filament usage reporting
                 try:
                     await _store_spoolman_print_data(

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

@@ -154,6 +154,9 @@ class NotificationProvider(Base):
     on_first_layer_complete: Mapped[bool | None] = mapped_column(
         Boolean, default=False
     )  # First layer finished printing
+    # A pause the slicer put into the print is coming up (#2984). Off by
+    # default: nothing new arrives on upgrade.
+    on_print_pause_upcoming: Mapped[bool | None] = mapped_column(Boolean, default=False, server_default=false())
     # Messages another application sends through Bambuddy (POST /notifications/app-message),
     # e.g. Bambuddy Orders' "an order needs you". Off by default: nothing new arrives on upgrade.
     on_app_message: Mapped[bool | None] = mapped_column(Boolean, default=False, server_default=false())

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

@@ -169,6 +169,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "First Layer Complete",
         "body_template": "{printer}: {filename}\nLayer 1/{total_layers} done",
     },
+    {
+        "event_type": "print_pause_upcoming",
+        "name": "Pause Coming Up",
+        "title_template": "Pause Coming Up",
+        "body_template": "{printer}: {filename}\nPauses in {time_to_pause} (about {eta}), at layer {layer}/{total_layers}",
+    },
     {
         "event_type": "test",
         "name": "Test Notification",

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

@@ -105,6 +105,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - First layer complete
     on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
 
+    # Event triggers - A slicer pause coming up (#2984)
+    on_print_pause_upcoming: bool = Field(default=False, description="Notify before a pause in the print")
+
     # Messages from connected apps (POST /notifications/app-message)
     on_app_message: bool = Field(default=False, description="Deliver messages other applications send")
 
@@ -221,6 +224,9 @@ class NotificationProviderUpdate(IgnoresNullForRequiredColumns):
     # Event triggers - First layer complete
     on_first_layer_complete: bool | None = None
 
+    # Event triggers - A slicer pause coming up
+    on_print_pause_upcoming: bool | None = None
+
     # Messages from connected apps
     on_app_message: bool | None = None
 

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

@@ -110,6 +110,16 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "ha_sensor_alert": ["printer", "sensor", "state", "timestamp", "app_name"],
     "location_ha_sensor_alert": ["location", "sensor", "state", "timestamp", "app_name"],
     "first_layer_complete": ["printer", "filename", "total_layers", "timestamp", "app_name"],
+    "print_pause_upcoming": [
+        "printer",
+        "filename",
+        "time_to_pause",
+        "eta",
+        "layer",
+        "total_layers",
+        "timestamp",
+        "app_name",
+    ],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -348,6 +358,16 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:35",
         "app_name": "Bambuddy",
     },
+    "print_pause_upcoming": {
+        "printer": "Bambu X1C",
+        "filename": "Wifi_QR_NFC.3mf",
+        "time_to_pause": "5m",
+        "eta": "16:05",
+        "layer": "42",
+        "total_layers": "120",
+        "timestamp": "2024-01-15 16:00",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

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

@@ -348,6 +348,14 @@ class PrintOptionsResponse(BaseModel):
     filament_tangle_detect: bool = False
 
 
+class NextPause(BaseModel):
+    """The next pause the slicer put into the running print (#2984)."""
+
+    layer: int
+    # Minutes until the printer gets there; None while it reports no time left.
+    minutes: int | None = None
+
+
 class PrinterStatus(BaseModel):
     id: int
     name: str
@@ -435,6 +443,8 @@ class PrinterStatus(BaseModel):
     last_ams_update: float = 0.0
     # Number of printable objects in current print (for skip objects feature)
     printable_objects_count: int = 0
+    # The next pause the slicer put into the print, while it is running (#2984)
+    next_pause: NextPause | None = None
     # Fan speeds (0-100 percentage, None if not available for this model)
     cooling_fan_speed: int | None = None  # Part cooling fan
     big_fan1_speed: int | None = None  # Auxiliary fan

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

@@ -127,6 +127,11 @@ class AppSettings(BaseModel):
         default=35.0, description="Bed temperature threshold for cooled notification (°C)"
     )
 
+    # Heads-up before a pause the slicer put into the print (#2984)
+    pause_warning_minutes: int = Field(
+        default=5, description="Minutes before a pause to send the pause-coming-up notification"
+    )
+
     # AMS threshold settings for humidity and temperature coloring
     ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
     ams_humidity_fair: int = Field(
@@ -747,6 +752,7 @@ class AppSettingsUpdate(BaseModel):
     language: str | None = None
     notification_language: str | None = None
     bed_cooled_threshold: float | None = None
+    pause_warning_minutes: int | None = Field(default=None, ge=1, le=120)
     ams_humidity_good: int | None = None
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None

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

@@ -1149,6 +1149,11 @@ class PrinterState:
     printable_objects: dict = field(default_factory=dict)
     # Objects that have been skipped during the current print
     skipped_objects: list = field(default_factory=list)
+    # Pauses the slicer put into the running print, from its archived 3MF
+    # (#2984): threemf_tools.PausePoint, in print order. Empty when unknown.
+    pause_points: list = field(default_factory=list)
+    # Layers of those pauses a heads-up notification already went out for.
+    pause_warned_layers: set = field(default_factory=set)
     # Fan speeds (0-100 percentage, None if not available for this model)
     cooling_fan_speed: int | None = None  # Part cooling fan
     big_fan1_speed: int | None = None  # Auxiliary fan
@@ -4465,6 +4470,16 @@ class BambuMQTTClient:
             self._eop_probe_armed = False
             self._eop_probe_last = {}
 
+    @property
+    def layer_before_new_print(self) -> int:
+        """The layer the printer was on when a print start was detected, until
+        it reports another (#3317); 0 when none.
+
+        For a print that is resumed rather than new, which a restart while it
+        was paused makes look new, that is the layer it is still on (#2984).
+        """
+        return self._stale_layer_num
+
     def _is_stale_layer(self, value) -> bool:
         """Whether a frame's `layer_num` is the previous print's last layer (#3317)."""
         if not self._stale_layer_num:
@@ -5800,6 +5815,9 @@ class BambuMQTTClient:
         if is_new_print or is_file_change:
             # Clear any old HMS errors when a new print starts
             self.state.hms_errors = []
+            # The last print's pauses; this one's come from its 3MF (#2984).
+            self.state.pause_points = []
+            self.state.pause_warned_layers = set()
             # Reset layer tracking for new print (needed for layer-based timelapse)
             # #3317: only above 1. Every print reports layer 1 first, which
             # clears a higher value; a previous print that ended on layer 1

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

@@ -2630,6 +2630,44 @@ class NotificationService:
             variables=variables,
         )
 
+    async def on_print_pause_upcoming(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        *,
+        minutes: int,
+        layer: int,
+        total_layers: int,
+        db: AsyncSession,
+    ):
+        """A pause the slicer put into the print is coming up (#2984)."""
+        providers = await self._get_providers_for_event(db, "on_print_pause_upcoming", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            # At least a minute: a pause seconds away still reads as "1m".
+            "time_to_pause": self._format_duration(max(minutes, 1) * 60),
+            "eta": await self._format_eta(max(minutes * 60, 1), db),
+            "layer": str(layer),
+            "total_layers": str(total_layers),
+        }
+
+        title, message = await self._build_message_from_template(db, "print_pause_upcoming", variables)
+        await self._send_to_providers(
+            providers,
+            title,
+            message,
+            db,
+            "print_pause_upcoming",
+            printer_id,
+            printer_name,
+            variables=variables,
+        )
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()

+ 164 - 0
backend/app/services/pause_eta.py

@@ -0,0 +1,164 @@
+"""Time to the next pause the slicer put into a print, and a heads-up before it (#2984).
+
+The pauses come from the plate's G-code (threemf_tools.parse_gcode_pause_points)
+with the slicer's time left at each. The time to one is the printer's time left
+minus that: both count down to the end of the same print.
+
+The printer manager serializes ``next_pause`` into every status, so nothing here
+imports it at module level.
+"""
+
+import logging
+import math
+import time
+
+from backend.app.core.tasks import spawn_background_task
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_WARNING_MINUTES = 5
+# The largest heads-up the setting allows. A pause further away than this is
+# not looked at again until it comes closer, so the setting is read at most
+# once per check interval, and only while a pause is this close.
+MAX_WARNING_MINUTES = 120
+# Time left is reported in whole minutes; checking more often finds nothing new.
+_CHECK_INTERVAL_SECONDS = 30.0
+
+_last_check: dict[int, float] = {}
+
+
+def next_pause(state) -> dict | None:
+    """The next pause of the running print: ``{"layer", "minutes"}``, or None.
+
+    The next is the first on a layer above the one printing: the printer is
+    on a pause's layer while it waits there and after it resumes, so that
+    pause is behind it. ``minutes`` is None while the printer reports no time
+    left (the start of a print); the layer is known all the same. Only while
+    RUNNING: a paused print is waiting at its pause, or was paused by hand.
+    """
+    if getattr(state, "state", None) != "RUNNING":
+        return None
+    layer_now = getattr(state, "layer_num", 0) or 0
+    remaining_now = getattr(state, "remaining_time", 0) or 0
+    # The slicer's times are for standard speed. In another mode the printer
+    # rescales its own time left, by a factor not measured yet, so a time to
+    # the pause worked out from both would be wrong; the layer is still right.
+    standard_speed = getattr(state, "speed_level", 2) == 2
+    for point in getattr(state, "pause_points", None) or ():
+        if point.layer > layer_now:
+            minutes = None
+            if standard_speed and remaining_now > 0 and point.remaining_minutes is not None:
+                minutes = max(0, remaining_now - point.remaining_minutes)
+            return {"layer": point.layer, "minutes": minutes}
+    return None
+
+
+def observe(printer_id: int, state) -> None:
+    """Check on a status push whether a pause is close enough to warn about.
+
+    Cheap on every push: the setting is read in a task, at most once per
+    check interval, and only while an unwarned pause is within the largest
+    heads-up the setting allows.
+    """
+    upcoming = next_pause(state)
+    if (
+        upcoming is None
+        or upcoming["minutes"] is None
+        or upcoming["minutes"] > MAX_WARNING_MINUTES
+        or upcoming["layer"] in state.pause_warned_layers
+    ):
+        return
+    now = time.monotonic()
+    if now - _last_check.get(printer_id, -math.inf) < _CHECK_INTERVAL_SECONDS:
+        return
+    _last_check[printer_id] = now
+    spawn_background_task(_warn_if_due(printer_id, upcoming["layer"]), name=f"pause-warning-{printer_id}")
+
+
+async def warning_minutes(db) -> int:
+    """The heads-up setting, in minutes; the default for a missing or bad value."""
+    from backend.app.api.routes.settings import get_setting
+
+    value = await get_setting(db, "pause_warning_minutes")
+    try:
+        minutes = int(value) if value else DEFAULT_WARNING_MINUTES
+    except (TypeError, ValueError):
+        return DEFAULT_WARNING_MINUTES
+    return min(max(minutes, 1), MAX_WARNING_MINUTES)
+
+
+# Kept on the print's archive under this key, so a restart does not warn again.
+_WARNED_KEY = "pause_warned_layers"
+
+
+def warned_layers_of(archive, state) -> set[int]:
+    """The pauses this print was warned about before a restart.
+
+    Only for the same run: a reprint reuses its archive row, and its
+    subtask_id is the one thing that tells the runs apart.
+    """
+    saved = (getattr(archive, "extra_data", None) or {}).get(_WARNED_KEY) or {}
+    subtask_id = str(getattr(state, "subtask_id", "") or "")
+    if not isinstance(saved, dict) or subtask_id in ("", "0") or saved.get("subtask_id") != subtask_id:
+        return set()
+    return {layer for layer in saved.get("layers") or [] if isinstance(layer, int)}
+
+
+async def _remember_warned(db, printer_id: int, state) -> None:
+    from backend.app.main import running_print_archive
+
+    archive = await running_print_archive(db, printer_id, state, printing_only=True, by_name=False)
+    if archive is None:
+        return
+    subtask_id = str(getattr(state, "subtask_id", "") or "")
+    archive.extra_data = {
+        **(archive.extra_data or {}),
+        _WARNED_KEY: {"subtask_id": subtask_id, "layers": sorted(state.pause_warned_layers)},
+    }
+    await db.commit()
+
+
+async def _warn_if_due(printer_id: int, layer: int) -> None:
+    from backend.app.core.database import async_session
+    from backend.app.models.printer import Printer
+    from backend.app.services.notification_service import notification_service
+    from backend.app.services.printer_manager import printer_manager
+
+    try:
+        async with async_session() as db:
+            lead = await warning_minutes(db)
+            client = printer_manager.get_client(printer_id)
+            if client is None:
+                return
+            state = client.state
+            # Read again after the await: the print may have moved on.
+            upcoming = next_pause(state)
+            if (
+                upcoming is None
+                or upcoming["layer"] != layer
+                or upcoming["minutes"] is None
+                or upcoming["minutes"] > lead
+                or layer in state.pause_warned_layers
+            ):
+                return
+            state.pause_warned_layers.add(layer)
+            printer = await db.get(Printer, printer_id)
+            if printer is None:
+                return
+            await notification_service.on_print_pause_upcoming(
+                printer_id,
+                printer.name,
+                state.subtask_name or state.gcode_file or "Unknown",
+                minutes=upcoming["minutes"],
+                layer=layer,
+                total_layers=state.total_layers,
+                db=db,
+            )
+            try:
+                await _remember_warned(db, printer_id, state)
+            except Exception:
+                logger.warning(
+                    "[Printer %s] Could not keep the pause heads-up on the archive", printer_id, exc_info=True
+                )
+    except Exception:
+        logger.warning("[Printer %s] Pause heads-up failed", printer_id, exc_info=True)

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

@@ -17,6 +17,7 @@ from backend.app.services.bambu_mqtt import (
     PrinterState,
     get_stage_name,
 )
+from backend.app.services.pause_eta import next_pause
 from backend.app.utils.ams_humidity import ams_humidity_percent
 from backend.app.utils.kprofile_lookup import build_slot_k_resolver
 
@@ -1767,6 +1768,8 @@ def printer_state_to_dict(
         "stg_cur_name": get_derived_status_name(state, model),
         # Printable objects count for skip objects feature
         "printable_objects_count": len(state.printable_objects),
+        # The next pause the slicer put into the running print (#2984)
+        "next_pause": next_pause(state),
         # Fan speeds (0-100 percentage, None if not available)
         "cooling_fan_speed": state.cooling_fan_speed,
         "big_fan1_speed": state.big_fan1_speed,

+ 90 - 9
backend/app/utils/threemf_tools.py

@@ -6,12 +6,14 @@ accurate partial usage reporting for multi-material prints.
 """
 
 import hashlib
+import io
 import json
 import logging
 import math
 import re
 import zipfile
 from collections import OrderedDict
+from collections.abc import Iterable
 from dataclasses import dataclass, field
 from pathlib import Path
 from threading import Lock
@@ -199,15 +201,7 @@ def extract_layer_filament_usage_from_3mf(
     """
     try:
         with zipfile.ZipFile(file_path, "r") as zf:
-            names = zf.namelist()
-            gcode_path = select_plate_gcode_name(names, plate_id)
-            if gcode_path is None:
-                # No plate asked for, or a file whose single G-code member
-                # doesn't follow the plate_N naming convention (non-Bambu
-                # slicers) — the lone member is unambiguous either way.
-                gcode_files = [f for f in names if f.endswith(".gcode")]
-                if plate_id is None or len(gcode_files) == 1:
-                    gcode_path = default_plate_gcode_name(names)
+            gcode_path = _printed_plate_gcode_name(zf.namelist(), plate_id)
             if gcode_path is None:
                 return None
 
@@ -218,6 +212,93 @@ def extract_layer_filament_usage_from_3mf(
         return None
 
 
+def _printed_plate_gcode_name(names: list[str], plate_id: int | None) -> str | None:
+    """The G-code member of the plate that printed, or None.
+
+    Exactly ``plate_id`` when it is in the file. With no plate asked for, or a
+    file whose single G-code member doesn't follow the plate_N naming
+    convention (non-Bambu slicers), the lone or lowest member: unambiguous
+    either way. Never another plate of a multi-plate file.
+    """
+    gcode_path = select_plate_gcode_name(names, plate_id)
+    if gcode_path is None:
+        gcode_files = [f for f in names if f.endswith(".gcode")]
+        if plate_id is None or len(gcode_files) == 1:
+            gcode_path = default_plate_gcode_name(names)
+    return gcode_path
+
+
+@dataclass(frozen=True)
+class PausePoint:
+    """A pause the slicer put into the print (#2984).
+
+    ``layer`` is the layer the printer reports while it waits: Bambu Studio and
+    OrcaSlicer write the pause after the layer-change G-code (``M73 L<n>``),
+    at the start of that layer. ``remaining_minutes`` is the slicer's time left
+    at that point, from the last ``M73 P<pct> R<min>`` before it, or None when
+    the G-code carries no such line.
+    """
+
+    layer: int
+    remaining_minutes: int | None
+
+
+def parse_gcode_pause_points(gcode_content: str) -> list[PausePoint]:
+    """The pauses in a plate's G-code, in print order.
+
+    A pause is ``M400 U1``: the pause G-code of every Bambu printer profile,
+    written by both slicers after their ``; PAUSE_PRINTING`` tag, and by hand in
+    a custom layer G-code. Bambu's start and end G-code never use it. One per
+    layer: a second pause on a layer the printer already stopped at adds no
+    new point to warn about.
+    """
+    return _pause_points(gcode_content.splitlines())
+
+
+def _pause_points(lines: Iterable[str]) -> list[PausePoint]:
+    pauses: list[PausePoint] = []
+    layer = 0
+    remaining: int | None = None
+    for raw in lines:
+        line = raw.lstrip()
+        if not line.startswith(("M73", "M400", "m73", "m400")):
+            continue
+        parts = line.split(";", 1)[0].split()
+        cmd = parts[0].upper() if parts else ""
+        params = [p.upper() for p in parts[1:]]
+        if cmd == "M73":
+            for param in params:
+                try:
+                    if param.startswith("L"):
+                        layer = int(param[1:])
+                    elif param.startswith("R"):
+                        remaining = int(param[1:])
+                except ValueError:
+                    pass  # A malformed value leaves the last good one
+        elif cmd == "M400" and "U1" in params:
+            if not pauses or pauses[-1].layer != layer:
+                pauses.append(PausePoint(layer=layer, remaining_minutes=remaining))
+    return pauses
+
+
+def extract_pause_points_from_3mf(file_path: Path, plate_id: int | None = None) -> list[PausePoint]:
+    """The pauses of the plate that is printing, or none when unreadable.
+
+    Line by line: a plate's G-code can run to 100 MB, and only its M73 and
+    M400 lines are wanted.
+    """
+    try:
+        with zipfile.ZipFile(file_path, "r") as zf:
+            gcode_path = _printed_plate_gcode_name(zf.namelist(), plate_id)
+            if gcode_path is None:
+                return []
+            with zf.open(gcode_path) as member:
+                return _pause_points(io.TextIOWrapper(member, encoding="utf-8", errors="ignore"))
+    except Exception:
+        logger.debug("Could not read pauses from %s", file_path, exc_info=True)
+        return []
+
+
 def get_cumulative_usage_at_layer(
     layer_usage: dict[int, dict[int, float]],
     target_layer: int,

+ 122 - 0
backend/tests/integration/test_pause_load_paths_2984.py

@@ -0,0 +1,122 @@
+"""Every print-start path that binds the print's archive also reads its slicer
+pauses (#2984), like the skip-objects list beside it."""
+
+import io
+import zipfile
+from contextlib import ExitStack
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from backend.app.core.config import settings as app_settings
+from backend.app.models.archive import PrintArchive
+from backend.tests.integration.test_print_start_name_match_ap2 import (  # noqa: F401
+    DISPATCH,
+    SUBTASK,
+    _clear_print_state,
+    _start,
+    _stuck_archive,
+)
+
+
+async def _start_from_cache(test_engine, printer_id: int, cached) -> None:
+    """``_start`` with the 3MF already pulled by the cover endpoint (#972)."""
+    session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
+    state = MagicMock(sdcard=True, sdcard_reported=True, subtask_id="222", progress=0.0)
+    with ExitStack() as stack:
+        for target, value in {
+            "backend.app.main.async_session": session_maker,
+            "backend.app.core.database.async_session": session_maker,
+            "backend.app.main.get_cached_3mf": MagicMock(return_value=cached),
+            "backend.app.main.cache_3mf_download": MagicMock(),
+            "backend.app.main._record_energy_start": AsyncMock(),
+            "backend.app.main._send_print_start_notification": AsyncMock(),
+            "backend.app.main._maybe_start_layer_timelapse": MagicMock(),
+            "backend.app.main._capture_timelapse_baseline_at_start": AsyncMock(),
+            "backend.app.main._store_spoolman_print_data": AsyncMock(),
+            "backend.app.main._schedule_archive_cost_estimate": MagicMock(),
+            "backend.app.services.usage_tracker.on_print_start": AsyncMock(),
+        }.items():
+            stack.enter_context(patch(target, value))
+        for name in ("notification_service", "smart_plug_manager", "ws_manager", "mqtt_relay"):
+            mock = stack.enter_context(patch(f"backend.app.main.{name}"))
+            for method in (
+                "on_print_start",
+                "send_print_start",
+                "send_archive_created",
+                "send_archive_updated",
+                "on_archive_created",
+            ):
+                setattr(mock, method, AsyncMock())
+        pm = stack.enter_context(patch("backend.app.main.printer_manager"))
+        pm.get_status = MagicMock(return_value=state)
+        pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TESTAP2"))
+        pm.get_client = MagicMock(return_value=None)
+
+        from backend.app.main import on_print_start
+
+        await on_print_start(
+            printer_id, {"filename": DISPATCH, "subtask_name": SUBTASK, "raw_data": {"subtask_id": "222"}}
+        )
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_resumed_on_its_subtask_id(test_engine, db_session, printer_factory):
+    printer = await printer_factory()
+    archive_id = await _stuck_archive(db_session, printer.id, "222")
+
+    with patch("backend.app.main._load_pauses_from_3mf") as load:
+        await _start(test_engine, printer.id, "222")
+
+    archive = await db_session.get(PrintArchive, archive_id)
+    load.assert_called_once()
+    assert load.call_args.args == (app_settings.base_dir / archive.file_path, printer.id)
+    # As a resumption: what was warned about and passed is kept.
+    assert set(load.call_args.kwargs) == {"warned_layers", "passed_through_layer"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_resumed_by_name(test_engine, db_session, printer_factory):
+    printer = await printer_factory()
+    archive_id = await _stuck_archive(db_session, printer.id, None)
+
+    with patch("backend.app.main._load_pauses_from_3mf") as load:
+        await _start(test_engine, printer.id, "222")
+
+    archive = await db_session.get(PrintArchive, archive_id)
+    load.assert_called_once()
+    assert load.call_args.args == (app_settings.base_dir / archive.file_path, printer.id)
+    # As a resumption: what was warned about and passed is kept.
+    assert set(load.call_args.kwargs) == {"warned_layers", "passed_through_layer"}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_a_new_archive_from_the_downloaded_3mf(test_engine, db_session, printer_factory, tmp_path):
+    printer = await printer_factory()
+    downloaded = tmp_path / f"{SUBTASK}.gcode.3mf"
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        zf.writestr("Metadata/plate_1.gcode", "M73 P0 R10\nM73 L2\nM400 U1\n")
+    downloaded.write_bytes(buf.getvalue())
+
+    printer_id = printer.id
+    with (
+        patch("backend.app.main._load_pauses_from_3mf") as load,
+        # The archive is copied under archive_dir; keep it out of the real one.
+        patch.object(app_settings, "base_dir", tmp_path),
+        patch.object(app_settings, "archive_dir", tmp_path / "archive"),
+    ):
+        await _start_from_cache(test_engine, printer_id, downloaded)
+        expected_base = app_settings.base_dir
+
+    db_session.expire_all()
+    archive = (await db_session.execute(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).scalar_one()
+    # The archived copy, not the download: that one may be removed meanwhile.
+    assert archive.file_path and expected_base / archive.file_path != downloaded
+    assert (expected_base / archive.file_path).is_file()
+    load.assert_called_once_with(expected_base / archive.file_path, printer_id)

+ 94 - 0
backend/tests/integration/test_pause_warning_2984.py

@@ -0,0 +1,94 @@
+"""The pause heads-up: its channel toggle, its setting, and the time on the status (#2984)."""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_channel_toggle_saves_on_create_and_update(async_client: AsyncClient):
+    data = {
+        "name": "Pause heads-up",
+        "provider_type": "ntfy",
+        "config": {"server": "https://ntfy.sh", "topic": "test"},
+        "on_print_pause_upcoming": True,
+    }
+    created = await async_client.post("/api/v1/notifications/", json=data)
+    assert created.status_code == 200
+    assert created.json()["on_print_pause_upcoming"] is True
+
+    provider_id = created.json()["id"]
+    updated = await async_client.patch(f"/api/v1/notifications/{provider_id}", json={"on_print_pause_upcoming": False})
+    assert updated.status_code == 200
+    assert updated.json()["on_print_pause_upcoming"] is False
+
+    listed = await async_client.get("/api/v1/notifications/")
+    assert next(p for p in listed.json() if p["id"] == provider_id)["on_print_pause_upcoming"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_channel_toggle_off_by_default(async_client: AsyncClient):
+    data = {"name": "Plain", "provider_type": "ntfy", "config": {"server": "https://ntfy.sh", "topic": "t"}}
+    created = await async_client.post("/api/v1/notifications/", json=data)
+    assert created.json()["on_print_pause_upcoming"] is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_heads_up_minutes_setting(async_client: AsyncClient):
+    assert (await async_client.get("/api/v1/settings")).json()["pause_warning_minutes"] == 5
+
+    saved = await async_client.patch("/api/v1/settings", json={"pause_warning_minutes": 15})
+    assert saved.status_code == 200
+    assert (await async_client.get("/api/v1/settings")).json()["pause_warning_minutes"] == 15
+
+    for bad in (0, 121):
+        assert (await async_client.patch("/api/v1/settings", json={"pause_warning_minutes": bad})).status_code == 422
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_heads_up_reads_the_saved_setting(async_client: AsyncClient, db_session):
+    from backend.app.services.pause_eta import warning_minutes
+
+    await async_client.patch("/api/v1/settings", json={"pause_warning_minutes": 20})
+    assert await warning_minutes(db_session) == 20
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_status_reports_the_next_pause(async_client: AsyncClient, printer_factory):
+    from backend.app.services.bambu_mqtt import PrinterState
+    from backend.app.utils.threemf_tools import PausePoint
+
+    printer = await printer_factory()
+    state = PrinterState()
+    state.connected = True
+    state.state = "RUNNING"
+    state.layer_num = 1
+    state.remaining_time = 110
+    state.pause_points = [PausePoint(3, 78)]
+
+    with patch("backend.app.api.routes.printers.printer_manager") as mock_pm:
+        mock_pm.get_status = MagicMock(return_value=state)
+        mock_pm.is_awaiting_plate_clear = MagicMock(return_value=False)
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+    assert response.status_code == 200
+    assert response.json()["next_pause"] == {"layer": 3, "minutes": 32}
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration
+async def test_an_unparseable_stored_value_reads_back_as_the_default(async_client: AsyncClient, db_session):
+    from backend.app.models.settings import Settings
+
+    db_session.add(Settings(key="pause_warning_minutes", value="None"))
+    await db_session.commit()
+
+    response = await async_client.get("/api/v1/settings")
+    assert response.status_code == 200
+    assert response.json()["pause_warning_minutes"] == 5

+ 666 - 0
backend/tests/unit/test_pause_eta_2984.py

@@ -0,0 +1,666 @@
+"""Time to the next slicer pause, and the heads-up before it (#2984)."""
+
+import asyncio
+import io
+import zipfile
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.models.archive import PrintArchive
+from backend.app.services import pause_eta
+from backend.app.services.bambu_mqtt import PrinterState
+from backend.app.utils.threemf_tools import (
+    PausePoint,
+    extract_pause_points_from_3mf,
+    parse_gcode_pause_points,
+)
+
+# The shape Bambu Studio writes: the layer change (M73 L), then the pause,
+# with the slicer's time left (M73 P R) along the way.
+GCODE = """; HEADER_BLOCK_START
+M73 P0 R120
+; layer num/total_layer_count: 1/30
+M73 L1
+M991 S0 P0 ;notify layer change
+G1 X10 Y10 E1.2
+M73 P10 R108
+; layer num/total_layer_count: 2/30
+M73 L2
+G1 X20 Y10 E1.0
+M73 P35 R78
+; layer num/total_layer_count: 3/30
+M73 L3
+M991 S0 P2 ;notify layer change
+;PAUSE_PRINTING
+M400 U1
+G1 X30 Y10 E1.0
+M73 P60 R48
+M73 L4
+M400 ; wait for moves, not a pause
+M73 P80 R24
+M73 L5
+;PAUSE_PRINTING
+M400 U1
+M400 U1 ; a second pause on the same layer
+G1 X40 E1.0
+"""
+
+
+def _three_mf(members: dict[str, str]) -> bytes:
+    buf = io.BytesIO()
+    with zipfile.ZipFile(buf, "w") as zf:
+        for name, content in members.items():
+            zf.writestr(name, content)
+    return buf.getvalue()
+
+
+class TestParse:
+    def test_each_pause_has_its_layer_and_the_time_left_there(self):
+        assert parse_gcode_pause_points(GCODE) == [
+            PausePoint(layer=3, remaining_minutes=78),
+            PausePoint(layer=5, remaining_minutes=24),
+        ]
+
+    def test_a_plain_m400_is_not_a_pause(self):
+        assert parse_gcode_pause_points("M73 L2\nM73 P10 R50\nM400\nM400 S1\n") == []
+
+    def test_no_pauses_in_a_plain_print(self):
+        assert parse_gcode_pause_points("M73 P0 R10\nM73 L1\nG1 X1 E1\n") == []
+
+    def test_time_left_is_unknown_without_m73_r(self):
+        # A slicer that writes layers but no time left.
+        assert parse_gcode_pause_points("M73 L7\nM400 U1\n") == [PausePoint(layer=7, remaining_minutes=None)]
+
+    def test_lowercase_and_indented_commands(self):
+        assert parse_gcode_pause_points("  m73 p5 r40\nm73 l2\n  m400 u1\n") == [
+            PausePoint(layer=2, remaining_minutes=40)
+        ]
+
+    def test_a_malformed_value_keeps_the_last_good_one(self):
+        assert parse_gcode_pause_points("M73 P5 R40\nM73 L2\nM73 Lx Rabc\nM400 U1\n") == [
+            PausePoint(layer=2, remaining_minutes=40)
+        ]
+
+    def test_stealth_mode_time_is_not_taken_for_normal(self):
+        # M73 Q/S is the silent-mode estimate; R stays the normal one.
+        assert parse_gcode_pause_points("M73 P5 R40\nM73 Q5 S80\nM73 L2\nM400 U1\n") == [
+            PausePoint(layer=2, remaining_minutes=40)
+        ]
+
+
+class TestExtract:
+    def test_reads_the_plate_that_is_printing(self, tmp_path):
+        path = tmp_path / "two.gcode.3mf"
+        path.write_bytes(
+            _three_mf(
+                {
+                    "Metadata/plate_2.gcode": "M73 P1 R9\nM73 L4\nM400 U1\n",
+                    "Metadata/plate_1.gcode": GCODE,
+                }
+            )
+        )
+        assert [p.layer for p in extract_pause_points_from_3mf(path, 2)] == [4]
+        assert [p.layer for p in extract_pause_points_from_3mf(path, 1)] == [3, 5]
+
+    def test_not_another_plate_when_the_printed_one_is_missing(self, tmp_path):
+        path = tmp_path / "two.gcode.3mf"
+        path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE, "Metadata/plate_2.gcode": "M73 L4\nM400 U1\n"}))
+        assert extract_pause_points_from_3mf(path, 3) == []
+
+    def test_unreadable_file_has_no_pauses(self, tmp_path):
+        path = tmp_path / "broken.3mf"
+        path.write_bytes(b"not a zip")
+        assert extract_pause_points_from_3mf(path, 1) == []
+
+
+def _running(layer: int, remaining: int, points=None) -> PrinterState:
+    state = PrinterState()
+    state.state = "RUNNING"
+    state.layer_num = layer
+    state.remaining_time = remaining
+    state.total_layers = 30
+    state.subtask_name = "Wifi_QR"
+    state.pause_points = points if points is not None else [PausePoint(3, 78), PausePoint(5, 24)]
+    return state
+
+
+class TestNextPause:
+    def test_time_to_the_first_pause(self):
+        assert pause_eta.next_pause(_running(layer=1, remaining=110)) == {"layer": 3, "minutes": 32}
+
+    def test_moves_on_once_the_printer_is_on_the_pause_layer(self):
+        # On the pause's layer the printer waits there, or has resumed past it.
+        assert pause_eta.next_pause(_running(layer=3, remaining=78)) == {"layer": 5, "minutes": 54}
+
+    def test_none_after_the_last_pause(self):
+        assert pause_eta.next_pause(_running(layer=5, remaining=20)) is None
+
+    def test_none_when_the_print_is_paused(self):
+        state = _running(layer=1, remaining=110)
+        state.state = "PAUSE"
+        assert pause_eta.next_pause(state) is None
+
+    def test_none_without_pauses(self):
+        assert pause_eta.next_pause(_running(layer=1, remaining=110, points=[])) is None
+
+    def test_layer_only_while_the_printer_reports_no_time_left(self):
+        assert pause_eta.next_pause(_running(layer=0, remaining=0)) == {"layer": 3, "minutes": None}
+
+    def test_layer_only_when_the_gcode_had_no_time(self):
+        state = _running(layer=1, remaining=110, points=[PausePoint(3, None)])
+        assert pause_eta.next_pause(state) == {"layer": 3, "minutes": None}
+
+    def test_never_negative(self):
+        # The printer a little ahead of the slicer's estimate.
+        assert pause_eta.next_pause(_running(layer=2, remaining=70)) == {"layer": 3, "minutes": 0}
+
+    def test_the_last_minute_before_a_pause(self):
+        # The slicer writes time left in whole minutes: the last M73 R before
+        # the pause can come a minute ahead of it.
+        assert pause_eta.next_pause(_running(layer=2, remaining=78)) == {"layer": 3, "minutes": 0}
+
+    @pytest.mark.parametrize("speed_level", [1, 3, 4])
+    def test_layer_only_outside_standard_speed(self, speed_level):
+        state = _running(layer=1, remaining=110)
+        state.speed_level = speed_level
+        assert pause_eta.next_pause(state) == {"layer": 3, "minutes": None}
+
+    def test_standard_speed(self):
+        state = _running(layer=1, remaining=110)
+        state.speed_level = 2
+        assert pause_eta.next_pause(state) == {"layer": 3, "minutes": 32}
+
+
+class TestStatusPayloads:
+    def test_websocket_status_carries_it(self):
+        from backend.app.services.printer_manager import printer_state_to_dict
+
+        state = _running(layer=1, remaining=110)
+        state.connected = True
+        assert printer_state_to_dict(state)["next_pause"] == {"layer": 3, "minutes": 32}
+
+    def test_websocket_status_none_without_pauses(self):
+        from backend.app.services.printer_manager import printer_state_to_dict
+
+        state = _running(layer=1, remaining=110, points=[])
+        state.connected = True
+        assert printer_state_to_dict(state)["next_pause"] is None
+
+
+class TestNewPrintClears:
+    def test_the_layer_it_was_on_is_kept_for_a_resume(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
+        client.state.layer_num = 42
+        client._previous_gcode_state = "PAUSE"
+        client._process_message({"print": {"gcode_state": "RUNNING", "gcode_file": "same.3mf"}})
+        assert client.layer_before_new_print == 42
+
+    def test_pauses_and_warnings_of_the_last_print_are_dropped(self):
+        from backend.app.services.bambu_mqtt import BambuMQTTClient
+
+        client = BambuMQTTClient(ip_address="192.168.1.100", serial_number="TEST123", access_code="12345678")
+        client.state.pause_points = [PausePoint(3, 78)]
+        client.state.pause_warned_layers = {3}
+        client._previous_gcode_state = "IDLE"
+
+        client._process_message({"print": {"gcode_state": "RUNNING", "gcode_file": "next.3mf"}})
+
+        assert client.state.pause_points == []
+        assert client.state.pause_warned_layers == set()
+
+
+@pytest.fixture(autouse=True)
+def _fresh_throttle():
+    pause_eta._last_check.clear()
+    yield
+    pause_eta._last_check.clear()
+
+
+class TestObserve:
+    def test_spawns_a_check_when_a_pause_is_near(self):
+        with patch.object(pause_eta, "spawn_background_task") as spawn:
+            pause_eta.observe(1, _running(layer=2, remaining=85))
+        spawn.assert_called_once()
+        spawn.call_args.args[0].close()
+
+    def test_nothing_while_the_pause_is_far(self):
+        with patch.object(pause_eta, "spawn_background_task") as spawn:
+            pause_eta.observe(1, _running(layer=1, remaining=78 + pause_eta.MAX_WARNING_MINUTES + 1))
+        spawn.assert_not_called()
+
+    def test_nothing_for_a_pause_already_warned_about(self):
+        state = _running(layer=2, remaining=85)
+        state.pause_warned_layers.add(3)
+        with patch.object(pause_eta, "spawn_background_task") as spawn:
+            pause_eta.observe(1, state)
+        spawn.assert_not_called()
+
+    def test_nothing_while_the_time_is_unknown(self):
+        with patch.object(pause_eta, "spawn_background_task") as spawn:
+            pause_eta.observe(1, _running(layer=0, remaining=0))
+        spawn.assert_not_called()
+
+    def test_checks_at_most_once_per_interval(self):
+        with patch.object(pause_eta, "spawn_background_task") as spawn:
+            pause_eta.observe(1, _running(layer=2, remaining=85))
+            pause_eta.observe(1, _running(layer=2, remaining=84))
+            pause_eta.observe(2, _running(layer=2, remaining=84))
+        assert spawn.call_count == 2  # once per printer
+        for call in spawn.call_args_list:
+            call.args[0].close()
+
+
+class _Session:
+    def __init__(self, printer):
+        self.get = AsyncMock(return_value=printer)
+
+    async def __aenter__(self):
+        return self
+
+    async def __aexit__(self, *exc):
+        return False
+
+
+def _warn(state, *, lead: int | None, printer=SimpleNamespace(name="X1C")):
+    """Run the delayed check against ``state`` with the setting at ``lead``."""
+    client = SimpleNamespace(state=state)
+    notify = AsyncMock()
+    with (
+        patch("backend.app.services.printer_manager.printer_manager.get_client", return_value=client),
+        patch("backend.app.core.database.async_session", return_value=_Session(printer)),
+        patch(
+            "backend.app.api.routes.settings.get_setting", AsyncMock(return_value=None if lead is None else str(lead))
+        ),
+        patch("backend.app.services.notification_service.notification_service.on_print_pause_upcoming", notify),
+    ):
+        asyncio.run(pause_eta._warn_if_due(1, 3))
+    return notify
+
+
+class TestWarnIfDue:
+    def test_sends_within_the_default_five_minutes(self):
+        state = _running(layer=2, remaining=82)
+        notify = _warn(state, lead=None)
+        notify.assert_awaited_once()
+        kwargs = notify.await_args.kwargs
+        assert notify.await_args.args[:3] == (1, "X1C", "Wifi_QR")
+        assert (kwargs["minutes"], kwargs["layer"], kwargs["total_layers"]) == (4, 3, 30)
+        assert state.pause_warned_layers == {3}
+
+    def test_waits_while_further_away_than_the_setting(self):
+        state = _running(layer=2, remaining=90)
+        notify = _warn(state, lead=10)
+        notify.assert_not_awaited()
+        assert state.pause_warned_layers == set()
+
+    def test_a_longer_setting_sends_earlier(self):
+        notify = _warn(_running(layer=2, remaining=90), lead=15)
+        notify.assert_awaited_once()
+
+    def test_once_per_pause(self):
+        state = _running(layer=2, remaining=80)
+        _warn(state, lead=5)
+        notify = _warn(state, lead=5)
+        notify.assert_not_awaited()
+
+    def test_not_when_the_print_moved_past_it_meanwhile(self):
+        notify = _warn(_running(layer=3, remaining=76), lead=120)
+        notify.assert_not_awaited()
+
+    def test_bad_setting_falls_back_to_the_default(self):
+        notify = _warn(_running(layer=2, remaining=82), lead="soon")  # type: ignore[arg-type]
+        notify.assert_awaited_once()
+
+    def test_setting_is_clamped_to_the_largest_heads_up(self):
+        notify = _warn(_running(layer=1, remaining=78 + 200), lead=500)
+        notify.assert_not_awaited()
+
+
+class TestNotificationService:
+    def test_variables_for_the_template(self):
+        from backend.app.services.notification_service import NotificationService
+
+        service = NotificationService()
+        db = AsyncMock()
+        with (
+            patch.object(service, "_get_providers_for_event", AsyncMock(return_value=[MagicMock()])) as providers,
+            patch.object(service, "_build_message_from_template", AsyncMock(return_value=("t", "m"))) as build,
+            patch.object(service, "_send_to_providers", AsyncMock()) as send,
+            patch.object(service, "_format_eta", AsyncMock(return_value="16:05")) as eta,
+        ):
+            asyncio.run(
+                service.on_print_pause_upcoming(
+                    1, "X1C", "/data/Wifi_QR.gcode.3mf", minutes=0, layer=42, total_layers=120, db=db
+                )
+            )
+        assert providers.await_args.args[1] == "on_print_pause_upcoming"
+        assert build.await_args.args[1] == "print_pause_upcoming"
+        variables = build.await_args.args[2]
+        assert variables == {
+            "printer": "X1C",
+            "filename": "Wifi_QR",
+            "time_to_pause": "1m",
+            "eta": "16:05",
+            "layer": "42",
+            "total_layers": "120",
+        }
+        # A pause under a minute away is due now, not "Unknown".
+        assert eta.await_args.args[0] > 0
+        assert send.await_args.args[4] == "print_pause_upcoming"
+
+    def test_nothing_without_providers(self):
+        from backend.app.services.notification_service import NotificationService
+
+        service = NotificationService()
+        with (
+            patch.object(service, "_get_providers_for_event", AsyncMock(return_value=[])),
+            patch.object(service, "_send_to_providers", AsyncMock()) as send,
+        ):
+            asyncio.run(
+                service.on_print_pause_upcoming(1, "X1C", "a.3mf", minutes=3, layer=2, total_layers=9, db=AsyncMock())
+            )
+        send.assert_not_awaited()
+
+    def test_default_template_uses_only_its_variables(self):
+        from backend.app.models.notification_template import DEFAULT_TEMPLATES
+        from backend.app.schemas.notification_template import EVENT_VARIABLES, SAMPLE_DATA
+
+        template = next(t for t in DEFAULT_TEMPLATES if t["event_type"] == "print_pause_upcoming")
+        allowed = set(EVENT_VARIABLES["print_pause_upcoming"])
+        used = set(__import__("re").findall(r"\{([a-z_]+)\}", template["title_template"] + template["body_template"]))
+        assert used <= allowed
+        assert set(SAMPLE_DATA["print_pause_upcoming"]) == allowed
+
+
+class TestLoadFromArchive:
+    def _client(self):
+        state = PrinterState()
+        state.subtask_name = "Wifi_QR"
+        return SimpleNamespace(state=state)
+
+    def _run_load(self, client, path, *, replace_before_done=False, warned_layers=(), passed_through_layer=0):
+        from backend.app import main
+
+        spawned = []
+
+        async def go():
+            with (
+                patch.object(main.printer_manager, "get_client", return_value=client),
+                patch.object(main, "resolve_plate_id", return_value=1),
+                patch.object(main, "spawn_background_task", side_effect=lambda coro, name=None: spawned.append(coro)),
+            ):
+                main._load_pauses_from_3mf(
+                    path, 1, warned_layers=warned_layers, passed_through_layer=passed_through_layer
+                )
+            if replace_before_done:
+                client.state.pause_points = []  # a new print started meanwhile
+            await spawned[0]
+
+        asyncio.run(go())
+
+    def test_loads_the_pauses(self, tmp_path):
+        path = tmp_path / "p.gcode.3mf"
+        path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
+        client = self._client()
+        self._run_load(client, path)
+        assert [p.layer for p in client.state.pause_points] == [3, 5]
+
+    def test_with_the_layers_already_warned_about(self, tmp_path):
+        path = tmp_path / "p.gcode.3mf"
+        path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
+        client = self._client()
+        self._run_load(client, path, warned_layers={3})
+        assert client.state.pause_warned_layers == {3}
+
+    def test_without_the_pauses_a_resumed_print_has_passed(self, tmp_path):
+        path = tmp_path / "p.gcode.3mf"
+        path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
+        client = self._client()
+        self._run_load(client, path, passed_through_layer=3)
+        assert [p.layer for p in client.state.pause_points] == [5]
+
+    def test_streams_a_large_plate(self, tmp_path):
+        # Read line by line; the result is the same as for the whole text.
+        body = "G1 X1 E0.1\n" * 200_000
+        path = tmp_path / "big.gcode.3mf"
+        path.write_bytes(_three_mf({"Metadata/plate_1.gcode": body + GCODE}))
+        assert extract_pause_points_from_3mf(path, 1) == parse_gcode_pause_points(GCODE)
+
+    def test_dropped_when_another_print_started_meanwhile(self, tmp_path):
+        path = tmp_path / "p.gcode.3mf"
+        path.write_bytes(_three_mf({"Metadata/plate_1.gcode": GCODE}))
+        client = self._client()
+        self._run_load(client, path, replace_before_done=True)
+        assert client.state.pause_points == []
+
+
+class TestLoadedWhereObjectsAre:
+    """The pauses are read wherever a print's archive is bound at its start,
+    and again after a restart, like the skip-objects list."""
+
+    def test_print_start_reads_the_archive_s_pauses(self, tmp_path):
+        from backend.tests.unit.test_reprint_updates_subtask_id import _clear_dicts, _drive, _make_archive  # noqa: F401
+
+        archive = _make_archive(archive_id=31, stored_subtask_id=None)
+        with patch("backend.app.main._load_pauses_from_3mf") as load:
+            asyncio.run(_drive(tmp_path, archive, mqtt_subtask_id="2103771517"))
+
+        load.assert_called_once()
+        assert load.call_args.args == (tmp_path / archive.file_path, 1)
+
+    def _restore(self, *, objects, pauses, extra_data=None):
+        import logging
+
+        from backend.app import main
+
+        archive = MagicMock(id=360, file_path="archive/7/job/job.3mf", extra_data=extra_data or {})
+        client = MagicMock()
+        client.state = MagicMock(printable_objects=objects, pause_points=pauses)
+        db = AsyncMock()
+        db.scalar = AsyncMock(return_value=archive)
+        with (
+            patch.object(main, "printer_manager") as pm,
+            patch.object(main, "_load_objects_from_archive") as load_objects,
+            patch.object(main, "_load_pauses_from_3mf") as load_pauses,
+        ):
+            pm.get_client.return_value = client
+            asyncio.run(
+                main._restore_printable_objects(7, MagicMock(subtask_id="330151809"), db, logging.getLogger("t"))
+            )
+        return load_objects, load_pauses
+
+    def test_restart_reloads_the_pauses(self):
+        from backend.app.core.config import settings as app_settings
+
+        load_objects, load_pauses = self._restore(objects={}, pauses=[])
+        load_objects.assert_called_once()
+        assert load_pauses.call_args.args == (app_settings.base_dir / "archive/7/job/job.3mf", 7)
+        assert load_pauses.call_args.kwargs == {"warned_layers": set()}
+
+    def test_restart_keeps_the_pauses_already_warned_about(self):
+        _objects, load_pauses = self._restore(
+            objects={},
+            pauses=[],
+            extra_data={"pause_warned_layers": {"subtask_id": "330151809", "layers": [3]}},
+        )
+        assert load_pauses.call_args.kwargs == {"warned_layers": {3}}
+
+    def test_restart_reloads_the_pauses_when_the_objects_are_loaded(self):
+        load_objects, load_pauses = self._restore(objects={1: "cube"}, pauses=[])
+        load_objects.assert_not_called()
+        load_pauses.assert_called_once()
+
+    def test_loaded_pauses_are_left_alone(self):
+        load_objects, load_pauses = self._restore(objects={}, pauses=[PausePoint(3, 78)])
+        load_objects.assert_called_once()
+        load_pauses.assert_not_called()
+
+
+class TestStatusHook:
+    def test_every_status_push_is_checked(self):
+        from backend.app import main
+
+        state = _running(layer=2, remaining=85)
+        with patch.object(main.pause_eta, "observe") as observe:
+            asyncio.run(main.on_printer_status_change(1, state))
+        observe.assert_called_once_with(1, state)
+
+    def test_a_failing_check_does_not_stop_the_status_handler(self):
+        from backend.app import main
+
+        state = _running(layer=2, remaining=85)
+        with patch.object(main.pause_eta, "observe", side_effect=RuntimeError("boom")):
+            # Must not raise: the rest of the handler sends the status out.
+            asyncio.run(main.on_printer_status_change(1, state))
+
+
+class TestWarnedLayersOnTheArchive:
+    def _state(self, subtask_id="4242"):
+        state = _running(layer=2, remaining=82)
+        state.subtask_id = subtask_id
+        return state
+
+    def test_read_back_for_the_same_run(self):
+        archive = SimpleNamespace(extra_data={"pause_warned_layers": {"subtask_id": "4242", "layers": [3, 5]}})
+        assert pause_eta.warned_layers_of(archive, self._state()) == {3, 5}
+
+    def test_not_for_another_run_of_the_same_archive(self):
+        archive = SimpleNamespace(extra_data={"pause_warned_layers": {"subtask_id": "1111", "layers": [3]}})
+        assert pause_eta.warned_layers_of(archive, self._state()) == set()
+
+    @pytest.mark.parametrize("subtask_id", ["", "0"])
+    def test_not_for_a_print_without_a_run_id(self, subtask_id):
+        archive = SimpleNamespace(extra_data={"pause_warned_layers": {"subtask_id": subtask_id, "layers": [3]}})
+        assert pause_eta.warned_layers_of(archive, self._state(subtask_id)) == set()
+
+    @pytest.mark.parametrize("saved", [None, [], "3", {"subtask_id": "4242", "layers": ["x", 3]}])
+    def test_tolerates_what_it_did_not_write(self, saved):
+        archive = SimpleNamespace(extra_data={"pause_warned_layers": saved})
+        assert pause_eta.warned_layers_of(archive, self._state()) <= {3}
+
+    def test_kept_when_the_heads_up_goes_out(self):
+        state = self._state()
+        archive = SimpleNamespace(extra_data={"other": 1})
+        db = MagicMock(commit=AsyncMock())
+        with patch("backend.app.main.running_print_archive", AsyncMock(return_value=archive)):
+            state.pause_warned_layers.add(3)
+            asyncio.run(pause_eta._remember_warned(db, 1, state))
+        assert archive.extra_data == {"other": 1, "pause_warned_layers": {"subtask_id": "4242", "layers": [3]}}
+        db.commit.assert_awaited_once()
+
+    def test_warn_if_due_keeps_it(self):
+        state = self._state()
+        with patch.object(pause_eta, "_remember_warned", AsyncMock()) as remember:
+            _warn(state, lead=5)
+        remember.assert_awaited_once()
+
+    def test_a_failure_to_keep_it_is_not_a_failure_to_warn(self):
+        state = self._state()
+        with patch.object(pause_eta, "_remember_warned", AsyncMock(side_effect=RuntimeError("db"))):
+            notify = _warn(state, lead=5)
+        notify.assert_awaited_once()
+        assert state.pause_warned_layers == {3}
+
+
+class TestRecoveredFallbackArchive:
+    """A print that started without its 3MF (FTPS cool-off, slow transfer)
+    gets its pauses once the 3MF is recovered (#2957 / #3063)."""
+
+    async def _recover(self, test_engine, tmp_path, status, *, price=True):
+        from backend.app import main as main_module
+        from backend.app.core.config import settings as app_settings
+        from backend.tests.unit.test_fallback_archive_recovery_2957 import DISPATCH_FILENAME, _seed, _write_3mf
+
+        maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
+        async with maker() as db:
+            archive = await db.get(PrintArchive, archive_id)
+            archive.status = status
+            await db.commit()
+        source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
+        with (
+            patch.object(main_module, "async_session", maker),
+            patch.object(main_module, "_schedule_archive_cost_estimate"),
+            patch.object(main_module, "_load_pauses_from_3mf") as load,
+            patch.object(app_settings, "base_dir", tmp_path),
+            patch.object(app_settings, "archive_dir", tmp_path / "archive"),
+            patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
+        ):
+            assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source, price=price)
+        async with maker() as db:
+            archive = await db.get(PrintArchive, archive_id)
+        return load, printer_id, archive
+
+    @pytest.mark.asyncio
+    async def test_while_it_prints(self, test_engine, tmp_path):
+        load, printer_id, archive = await self._recover(test_engine, tmp_path, "printing")
+        load.assert_called_once_with(tmp_path / archive.file_path, printer_id)
+
+    @pytest.mark.asyncio
+    async def test_not_once_it_has_ended(self, test_engine, tmp_path):
+        load, _printer_id, _archive = await self._recover(test_engine, tmp_path, "failed")
+        load.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_not_from_its_own_completion(self, test_engine, tmp_path):
+        load, _printer_id, _archive = await self._recover(test_engine, tmp_path, "printing", price=False)
+        load.assert_not_called()
+
+
+def test_a_plate_that_cannot_be_told_does_not_break_the_print_start():
+    from backend.app import main
+
+    client = SimpleNamespace(state=PrinterState())
+    with (
+        patch.object(main.printer_manager, "get_client", return_value=client),
+        patch.object(main, "resolve_plate_id", side_effect=TypeError("odd gcode_file")),
+        patch.object(main, "spawn_background_task") as spawn,
+    ):
+        main._load_pauses_from_3mf("/nowhere.3mf", 1)  # must not raise
+    spawn.assert_not_called()
+
+
+def test_nothing_kept_without_the_print_s_archive():
+    state = _running(layer=2, remaining=82)
+    db = MagicMock(commit=AsyncMock())
+    with patch("backend.app.main.running_print_archive", AsyncMock(return_value=None)):
+        asyncio.run(pause_eta._remember_warned(db, 1, state))
+    db.commit.assert_not_awaited()
+
+
+class TestResumedArchive:
+    """A restart while the print was paused makes its resume a print start."""
+
+    def test_keeps_what_was_warned_and_passed(self):
+        from backend.app import main
+        from backend.app.core.config import settings as app_settings
+
+        state = _running(layer=0, remaining=78)
+        state.subtask_id = "4242"
+        client = SimpleNamespace(state=state, layer_before_new_print=3)
+        archive = SimpleNamespace(
+            file_path="archive/1/job/job.3mf",
+            extra_data={"pause_warned_layers": {"subtask_id": "4242", "layers": [3]}},
+        )
+        with (
+            patch.object(main.printer_manager, "get_client", return_value=client),
+            patch.object(main, "_load_pauses_from_3mf") as load,
+        ):
+            main._load_pauses_from_resumed_archive(archive, 1)
+        load.assert_called_once_with(
+            app_settings.base_dir / "archive/1/job/job.3mf", 1, warned_layers={3}, passed_through_layer=3
+        )
+
+    def test_without_a_client(self):
+        from backend.app import main
+
+        archive = SimpleNamespace(file_path="archive/1/job/job.3mf", extra_data={})
+        with (
+            patch.object(main.printer_manager, "get_client", return_value=None),
+            patch.object(main, "_load_pauses_from_3mf") as load,
+        ):
+            main._load_pauses_from_resumed_archive(archive, 1)
+        assert load.call_args.kwargs == {"warned_layers": (), "passed_through_layer": 0}

+ 142 - 0
frontend/src/__tests__/components/NotificationPauseUpcoming.test.tsx

@@ -0,0 +1,142 @@
+/**
+ * The "Pause Coming Up" channel toggle (#2984): in the add/edit modal, on the
+ * provider card, and saved under its own field.
+ */
+
+import { describe, it, expect, afterEach, vi } from 'vitest';
+import { screen, waitFor, within } 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 { AddNotificationModal } from '../../components/AddNotificationModal';
+import { NotificationProviderCard } from '../../components/NotificationProviderCard';
+import type { NotificationProvider } from '../../api/client';
+
+afterEach(() => {
+  server.resetHandlers();
+  vi.restoreAllMocks();
+});
+
+function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
+  return {
+    id: 1,
+    name: 'My ntfy',
+    provider_type: 'ntfy',
+    enabled: true,
+    config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
+    on_print_start: false,
+    on_print_complete: false,
+    on_print_failed: false,
+    on_print_stopped: false,
+    on_print_progress: false,
+    on_print_missing_spool_assignment: false,
+    on_printer_offline: false,
+    on_printer_error: false,
+    on_ai_failure_detection: false,
+    on_filament_low: false,
+    on_maintenance_due: false,
+    on_ams_humidity_high: false,
+    on_ams_temperature_high: false,
+    on_ams_ht_humidity_high: false,
+    on_ams_ht_temperature_high: false,
+    on_plate_not_empty: false,
+    on_bed_cooled: false,
+    on_first_layer_complete: false,
+    on_print_pause_upcoming: false,
+    on_queue_job_added: false,
+    on_queue_job_assigned: false,
+    on_queue_job_started: false,
+    on_queue_job_waiting: false,
+    on_queue_job_skipped: false,
+    on_queue_job_failed: false,
+    on_queue_completed: false,
+    on_stock_reorder_alert: false,
+    on_stock_break_alert: false,
+    quiet_hours_enabled: false,
+    quiet_hours_start: null,
+    quiet_hours_end: null,
+    daily_digest_enabled: false,
+    daily_digest_time: null,
+    printer_id: null,
+    last_success: null,
+    last_error: null,
+    last_error_at: null,
+    created_at: '2026-10-24T00:00:00Z',
+    updated_at: '2026-10-24T00:00:00Z',
+    ...overrides,
+  };
+}
+
+function modalToggle() {
+  return screen
+    .getAllByRole('switch')
+    .find((s) => s.closest('div')?.textContent?.match(/pause coming up/i))!;
+}
+
+describe('AddNotificationModal - Pause Coming Up', () => {
+  it('pre-fills the toggle from the provider', async () => {
+    render(<AddNotificationModal provider={buildProvider({ on_print_pause_upcoming: true })} onClose={() => undefined} />);
+    await screen.findByDisplayValue('My ntfy');
+    expect(modalToggle()).toHaveAttribute('aria-checked', 'true');
+  });
+
+  it('saves on_print_pause_upcoming', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json({ id: 1 });
+      }),
+    );
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
+    await screen.findByDisplayValue('My ntfy');
+
+    expect(modalToggle()).toHaveAttribute('aria-checked', 'false');
+    await user.click(modalToggle());
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+
+    expect(captured).toMatchObject({ on_print_pause_upcoming: true });
+  });
+
+  it('lists the event in the ntfy priority section once enabled', async () => {
+    render(<AddNotificationModal provider={buildProvider({ on_print_pause_upcoming: true })} onClose={() => undefined} />);
+    const sectionRoot = (await screen.findByText(/ntfy priority/i)).closest('div')!;
+    expect(within(sectionRoot).getByText(/pause coming up/i)).toBeInTheDocument();
+  });
+});
+
+describe('NotificationProviderCard - Pause Coming Up', () => {
+  it('shows the badge only when enabled', async () => {
+    const { unmount } = render(
+      <NotificationProviderCard provider={buildProvider({ on_print_pause_upcoming: true })} onEdit={vi.fn()} />,
+    );
+    expect(await screen.findByText('Pause')).toBeInTheDocument();
+    unmount();
+
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+    await screen.findByText('My ntfy');
+    expect(screen.queryByText('Pause')).not.toBeInTheDocument();
+  });
+
+  it('PATCHes on_print_pause_upcoming when toggled', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(
+      http.patch('*/api/v1/notifications/1', async ({ request }) => {
+        captured = (await request.json()) as Record<string, unknown>;
+        return HttpResponse.json(buildProvider({ on_print_pause_upcoming: true }));
+      }),
+    );
+    const user = userEvent.setup();
+    render(<NotificationProviderCard provider={buildProvider()} onEdit={vi.fn()} />);
+
+    await user.click(await screen.findByText(/event settings/i));
+    const row = (await screen.findByText('Pause Coming Up')).closest('div.flex')!;
+    await user.click(within(row as HTMLElement).getByRole('switch'));
+
+    await waitFor(() => expect(captured).toEqual({ on_print_pause_upcoming: true }));
+  });
+});

+ 91 - 0
frontend/src/__tests__/pages/PrintersPageNextPause.test.tsx

@@ -0,0 +1,91 @@
+/**
+ * The next pause the slicer put into the print, on the printer card (#2984).
+ */
+
+import { describe, it, expect } from 'vitest';
+import { screen, within } from '@testing-library/react';
+import { render } from '../utils';
+import { PrintersPage } from '../../pages/PrintersPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+
+const printers = [
+  {
+    id: 1,
+    name: 'X1 Carbon',
+    ip_address: '192.168.1.100',
+    serial_number: '00M09A350100001',
+    access_code: '12345678',
+    model: 'X1C',
+    enabled: true,
+    nozzle_diameter: 0.4,
+    nozzle_type: 'hardened_steel',
+    location: 'Workshop',
+    auto_archive: true,
+    is_active: true,
+    created_at: '2024-01-01T00:00:00Z',
+    updated_at: '2024-01-01T00:00:00Z',
+  },
+];
+
+const printing = {
+  connected: true,
+  temperatures: { nozzle: 220, bed: 60, chamber: 35 },
+  filename: 'wifi_qr.3mf',
+  current_print: 'wifi_qr',
+  wifi_signal: -50,
+  vt_tray: [],
+  speed_level: 2,
+  hms_errors: [],
+  state: 'RUNNING',
+  progress: 30,
+  layer_num: 9,
+  total_layers: 23,
+  remaining_time: 188,
+};
+
+function useStatus(status: Record<string, unknown>) {
+  server.use(
+    http.get('/api/v1/printers/', () => HttpResponse.json(printers)),
+    http.get('/api/v1/queue/', () => HttpResponse.json([])),
+    http.get('/api/v1/printers/:id/status', () => HttpResponse.json(status)),
+  );
+}
+
+describe('PrintersPage - next slicer pause (#2984)', () => {
+  it('shows the time to the next pause and when it comes', async () => {
+    useStatus({ ...printing, next_pause: { layer: 12, minutes: 20 } });
+
+    render(<PrintersPage />);
+
+    const pause = await screen.findByTestId('next-pause');
+    expect(pause).toHaveTextContent(/^20m to pause · \S/);
+    expect(pause).toHaveAttribute('title', 'Next pause added in the slicer, at layer 12');
+  });
+
+  it('a pause under a minute away reads as a minute, not zero', async () => {
+    useStatus({ ...printing, next_pause: { layer: 12, minutes: 0 } });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByTestId('next-pause')).toHaveTextContent(/^1m to pause/);
+  });
+
+  it('shows the layer while the printer reports no time left yet', async () => {
+    useStatus({ ...printing, remaining_time: 0, next_pause: { layer: 12, minutes: null } });
+
+    render(<PrintersPage />);
+
+    expect(await screen.findByTestId('next-pause')).toHaveTextContent('Pause at layer 12');
+  });
+
+  it('shows nothing for a print without pauses', async () => {
+    useStatus({ ...printing, next_pause: null });
+
+    render(<PrintersPage />);
+
+    const layers = await screen.findByText('9/23');
+    expect(within(layers.parentElement!.parentElement!).queryByTestId('next-pause')).not.toBeInTheDocument();
+    expect(screen.queryByTestId('next-pause')).not.toBeInTheDocument();
+  });
+});

+ 97 - 0
frontend/src/__tests__/pages/SettingsPagePauseWarning.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * How long before a slicer pause the "Pause Coming Up" notification goes out
+ * (#2984): a setting next to the bed-cooled threshold, saved as whole minutes
+ * within what the server accepts.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SettingsPage } from '../../pages/SettingsPage';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
+
+const settings = {
+  auto_archive: true,
+  currency: 'USD',
+  time_format: 'system',
+  date_format: 'system',
+  check_updates: false,
+  check_printer_firmware: false,
+  bed_cooled_threshold: 35,
+  pause_warning_minutes: 5,
+  // Set, so the page has no detected address of its own to save with any edit.
+  external_url: window.location.origin,
+};
+
+let saved: Array<Record<string, unknown>>;
+
+beforeEach(() => {
+  window.history.replaceState({}, '', '/?tab=notifications');
+  vi.mocked(localStorage.getItem).mockReset();
+  setAuthToken(null);
+  saved = [];
+  server.use(
+    http.get('/api/v1/settings/', () => HttpResponse.json(settings)),
+    http.put('/api/v1/settings/', async ({ request }) => {
+      const body = (await request.json()) as Record<string, unknown>;
+      saved.push(body);
+      return HttpResponse.json({ ...settings, ...body });
+    }),
+    http.get('/api/v1/printers/', () => HttpResponse.json([])),
+    http.get('/api/v1/notifications/', () => HttpResponse.json([])),
+    http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
+  );
+});
+
+async function field() {
+  const input = (await screen.findByLabelText('Pause Heads-Up')) as HTMLInputElement;
+  // Let the loaded settings land first; a change before that is overwritten.
+  await new Promise(resolve => setTimeout(resolve, 200));
+  return input;
+}
+
+describe('SettingsPage - pause heads-up minutes (#2984)', () => {
+  it('shows the saved value, and saves nothing unchanged', async () => {
+    render(<SettingsPage />);
+    expect((await field()).value).toBe('5');
+    await new Promise(resolve => setTimeout(resolve, 1000));
+    expect(saved).toEqual([]);
+  });
+
+  it('saves a changed value', async () => {
+    render(<SettingsPage />);
+    fireEvent.change(await field(), { target: { value: '15' } });
+    await waitFor(() => expect(saved.at(-1)?.pause_warning_minutes).toBe(15), { timeout: 3000 });
+  });
+
+  it('saves an out-of-range value within 1-120', async () => {
+    render(<SettingsPage />);
+    fireEvent.change(await field(), { target: { value: '500' } });
+    await waitFor(() => expect(saved.at(-1)?.pause_warning_minutes).toBe(120), { timeout: 3000 });
+  });
+
+  it('saves an out-of-range value once, and shows what was saved on leaving the field', async () => {
+    render(<SettingsPage />);
+    const input = await field();
+    fireEvent.change(input, { target: { value: '500' } });
+    await waitFor(() => expect(saved).toHaveLength(1), { timeout: 3000 });
+    // The server holds 120 now; 500 in the field must not read as a change
+    // still to save, or the page saves it again and again.
+    await new Promise(resolve => setTimeout(resolve, 1500));
+    expect(saved).toHaveLength(1);
+    fireEvent.blur(input);
+    expect(input.value).toBe('120');
+  });
+
+  it('an emptied field is the default: nothing to save, and it shows 5 on leaving', async () => {
+    render(<SettingsPage />);
+    const input = await field();
+    fireEvent.change(input, { target: { value: '' } });
+    await new Promise(resolve => setTimeout(resolve, 1000));
+    expect(saved).toEqual([]);
+    fireEvent.blur(input);
+    expect(input.value).toBe('5');
+  });
+});

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

@@ -734,6 +734,9 @@ export interface PrinterStatus {
   last_ams_update: number;
   // Number of printable objects in current print (for skip objects feature)
   printable_objects_count: number;
+  // The next pause the slicer put into the running print (#2984). minutes is
+  // null while the printer reports no time left yet.
+  next_pause?: { layer: number; minutes: number | null } | null;
   // Fan speeds (0-100 percentage, null if not available for this model)
   cooling_fan_speed: number | null;  // Part cooling fan
   big_fan1_speed: number | null;     // Auxiliary fan
@@ -1536,6 +1539,8 @@ export interface AppSettings {
   prometheus_token: string;
   // Bed cooled threshold
   bed_cooled_threshold: number;
+  // Minutes before a slicer pause to send the pause heads-up (#2984)
+  pause_warning_minutes: number;
   // Inventory low stock threshold
   low_stock_threshold: number;
   // Session policy (#1706) — admin-set ceiling, hours, [1, 720]
@@ -3223,6 +3228,7 @@ export interface NotificationProvider {
   on_location_ha_sensor_alert: boolean;
   // First layer complete
   on_first_layer_complete: boolean;
+  on_print_pause_upcoming: boolean;
   on_app_message: boolean;
   // Inventory stock alerts
   on_stock_reorder_alert: boolean;
@@ -3292,6 +3298,7 @@ export interface NotificationProviderCreate {
   on_location_ha_sensor_alert?: boolean;
   // First layer complete
   on_first_layer_complete?: boolean;
+  on_print_pause_upcoming?: boolean;
   on_app_message?: boolean;
   // Inventory stock alerts
   on_stock_reorder_alert?: boolean;
@@ -3354,6 +3361,7 @@ export interface NotificationProviderUpdate {
   on_location_ha_sensor_alert?: boolean;
   // First layer complete
   on_first_layer_complete?: boolean;
+  on_print_pause_upcoming?: boolean;
   on_app_message?: boolean;
   // Inventory stock alerts
   on_stock_reorder_alert?: boolean;

+ 10 - 0
frontend/src/components/AddNotificationModal.tsx

@@ -81,6 +81,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
     provider?.on_location_ha_sensor_alert ?? false
   );
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
+  const [onPrintPauseUpcoming, setOnPrintPauseUpcoming] = useState(provider?.on_print_pause_upcoming ?? false);
   const [onAppMessage, setOnAppMessage] = useState(provider?.on_app_message ?? false);
 
   const [notifyLiveActivities, setNotifyLiveActivities] = useState(
@@ -270,6 +271,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_ha_sensor_alert: onHaSensorAlert,
       on_location_ha_sensor_alert: onLocationHaSensorAlert,
       on_first_layer_complete: onFirstLayerComplete,
+      on_print_pause_upcoming: onPrintPauseUpcoming,
       on_app_message: onAppMessage,
     };
 
@@ -881,6 +883,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onFirstLayerComplete} onChange={setOnFirstLayerComplete} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.pauseUpcomingLabel')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.pauseUpcomingDescription')}</span>
+                  </div>
+                  <Toggle checked={onPrintPauseUpcoming} onChange={setOnPrintPauseUpcoming} />
+                </div>
               </div>
             </div>
 
@@ -971,6 +980,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               if (onPrintConfirmRequest) enabledEvents.push({ key: 'on_print_confirm_request', label: t('notifications.printConfirmRequest') });
               if (onBedCooled) enabledEvents.push({ key: 'on_bed_cooled', label: t('notifications.bedCooled') });
               if (onFirstLayerComplete) enabledEvents.push({ key: 'on_first_layer_complete', label: t('notifications.firstLayerCompleteLabel') });
+              if (onPrintPauseUpcoming) enabledEvents.push({ key: 'on_print_pause_upcoming', label: t('notifications.pauseUpcomingLabel') });
               if (onPrinterOffline) enabledEvents.push({ key: 'on_printer_offline', label: t('notifications.offline') });
               if (onPrinterError) enabledEvents.push({ key: 'on_printer_error', label: t('notifications.error') });
               if (onHaSensorAlert) enabledEvents.push({ key: 'on_ha_sensor_alert', label: t('notifications.haSensorAlert') });

+ 14 - 0
frontend/src/components/NotificationProviderCard.tsx

@@ -198,6 +198,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_first_layer_complete && (
               <span className="px-2 py-0.5 bg-emerald-100 dark:bg-emerald-600/20 text-emerald-700 dark:text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
             )}
+            {provider.on_print_pause_upcoming && (
+              <span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 text-xs rounded">{t('notifications.pauseUpcomingBadge')}</span>
+            )}
             {provider.on_app_message && (
               <span className="px-2 py-0.5 bg-sky-100 dark:bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs rounded">{t('notifications.appMessagesBadge')}</span>
             )}
@@ -374,6 +377,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.pauseUpcomingLabel')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.pauseUpcomingDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_print_pause_upcoming ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_print_pause_upcoming: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <div>
                     <p className="text-sm text-white">{t('notifications.missingSpoolAssignmentLabel')}</p>

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

@@ -910,6 +910,9 @@ export default {
       openSettings: 'Einstellungen öffnen',
     },
     estimatedCompletion: 'Geschätzte Fertigstellungszeit',
+    pauseIn: '{{time}} bis Pause · {{eta}}',
+    pauseAtLayer: 'Pause bei Schicht {{layer}}',
+    nextPauseAtLayer: 'Nächste im Slicer gesetzte Pause, bei Schicht {{layer}}',
     plateNumber: 'Platte {{number}}',
     slotOptions: 'Slot-Optionen',
     // AMS hover popup
@@ -2194,6 +2197,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Bett-Abkühlung Schwellenwert',
     bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt',
+    pauseWarningMinutes: 'Pausen-Vorwarnung',
+    pauseWarningMinutesDescription: 'Minuten vor einer im Slicer gesetzten Pause, zu denen die Benachrichtigung "Pause steht bevor" gesendet wird',
     userNotificationsEnabled: 'Benutzerbenachrichtigungen',
     userNotificationsEnabledDescription: 'Aktiviert das Benutzerbenachrichtigungsmenü und E-Mail-Benachrichtigungen für Druckereignisse. Erfordert Erweiterte Authentifizierung.',
     userNotificationsDisabledHint: 'Erweiterte Authentifizierung aktivieren, um Benutzerbenachrichtigungen zu verwenden.',
@@ -6679,6 +6684,9 @@ export default {
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
     firstLayerCompleteLabel: 'Erste Schicht fertig',
     firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
+    pauseUpcomingLabel: 'Pause steht bevor',
+    pauseUpcomingDescription: 'Vorwarnung vor einer im Slicer gesetzten Pause',
+    pauseUpcomingBadge: 'Pause',
     connectedApps: 'Verbundene Apps',
     appMessages: 'Nachrichten verbundener Apps',
     appMessagesDescription: 'Nachrichten zustellen, die andere Anwendungen über Bambuddy senden, z. B. Bambuddy Orders',

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

@@ -917,6 +917,9 @@ export default {
       openSettings: 'Open settings',
     },
     estimatedCompletion: 'Estimated completion time',
+    pauseIn: '{{time}} to pause · {{eta}}',
+    pauseAtLayer: 'Pause at layer {{layer}}',
+    nextPauseAtLayer: 'Next pause added in the slicer, at layer {{layer}}',
     plateNumber: 'Plate {{number}}',
     slotOptions: 'Slot options',
     // AMS hover popup
@@ -2216,6 +2219,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Bed Cooled Threshold',
     bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print',
+    pauseWarningMinutes: 'Pause Heads-Up',
+    pauseWarningMinutesDescription: 'Minutes before a pause added in the slicer to send the "Pause coming up" notification',
     userNotificationsEnabled: 'User Notifications',
     userNotificationsEnabledDescription: 'Enable the user notifications menu and email notifications for print job events. Requires Advanced Authentication.',
     userNotificationsDisabledHint: 'Enable Advanced Authentication to use user notifications.',
@@ -6732,6 +6737,9 @@ export default {
     bedCooledDescription: 'Bed cooled below threshold after print',
     firstLayerCompleteLabel: 'First Layer Complete',
     firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
+    pauseUpcomingLabel: 'Pause Coming Up',
+    pauseUpcomingDescription: 'Heads-up before a pause added in the slicer',
+    pauseUpcomingBadge: 'Pause',
     connectedApps: 'Connected apps',
     appMessages: 'Messages from connected apps',
     appMessagesDescription: 'Deliver messages other applications send through Bambuddy, e.g. Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: 'Abrir ajustes',
     },
     estimatedCompletion: 'Hora estimada de finalización',
+    pauseIn: '{{time}} hasta la pausa · {{eta}}',
+    pauseAtLayer: 'Pausa en la capa {{layer}}',
+    nextPauseAtLayer: 'Próxima pausa añadida en el laminador, en la capa {{layer}}',
     plateNumber: 'Cama {{number}}',
     slotOptions: 'Opciones de la ranura',
     // AMS hover popup
@@ -2197,6 +2200,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Umbral de cama enfriada',
     bedCooledThresholdDescription: 'Temperatura por debajo de la cual se considera que la cama se ha enfriado tras una impresión',
+    pauseWarningMinutes: 'Aviso de pausa',
+    pauseWarningMinutesDescription: 'Minutos antes de una pausa añadida en el laminador para enviar la notificación "Pausa próxima"',
     userNotificationsEnabled: 'Notificaciones de usuario',
     userNotificationsEnabledDescription: 'Activa el menú de notificaciones de usuario y las notificaciones por correo de los eventos de los trabajos de impresión. Requiere autenticación avanzada.',
     userNotificationsDisabledHint: 'Active la autenticación avanzada para usar las notificaciones de usuario.',
@@ -6687,6 +6692,9 @@ export default {
     bedCooledDescription: 'La cama se enfrió por debajo del umbral tras la impresión',
     firstLayerCompleteLabel: 'Primera capa completada',
     firstLayerCompleteDescription: 'Notificar con una captura cuando termina la primera capa',
+    pauseUpcomingLabel: 'Pausa próxima',
+    pauseUpcomingDescription: 'Aviso antes de una pausa añadida en el laminador',
+    pauseUpcomingBadge: 'Pausa',
     connectedApps: 'Aplicaciones conectadas',
     appMessages: 'Mensajes de aplicaciones conectadas',
     appMessagesDescription: 'Entregar los mensajes que otras aplicaciones envían a través de Bambuddy, p. ej. Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: 'Ouvrir les paramètres',
     },
     estimatedCompletion: 'Fin estimée',
+    pauseIn: '{{time}} avant la pause · {{eta}}',
+    pauseAtLayer: 'Pause à la couche {{layer}}',
+    nextPauseAtLayer: 'Prochaine pause ajoutée dans le slicer, à la couche {{layer}}',
     plateNumber: 'Plaque {{number}}',
     slotOptions: 'Options du slot',
     // AMS hover popup
@@ -2149,6 +2152,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Seuil de refroidissement du plateau',
     bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi',
+    pauseWarningMinutes: 'Préavis de pause',
+    pauseWarningMinutesDescription: 'Minutes avant une pause ajoutée dans le slicer pour envoyer la notification "Pause imminente"',
     userNotificationsEnabled: 'Notifications utilisateur',
     userNotificationsEnabledDescription: "Active le menu de notifications utilisateur et les notifications par e-mail pour les événements d'impression. Nécessite l'authentification avancée.",
     userNotificationsDisabledHint: "Activez l'authentification avancée pour utiliser les notifications utilisateur.",
@@ -6667,6 +6672,9 @@ export default {
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
     firstLayerCompleteLabel: 'Première couche terminée',
     firstLayerCompleteDescription: 'Notification avec photo après la première couche',
+    pauseUpcomingLabel: 'Pause imminente',
+    pauseUpcomingDescription: 'Préavis avant une pause ajoutée dans le slicer',
+    pauseUpcomingBadge: 'Pause',
     connectedApps: 'Applications connectées',
     appMessages: 'Messages des applications connectées',
     appMessagesDescription: 'Transmettre les messages que d\'autres applications envoient via Bambuddy, p. ex. Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: 'Apri impostazioni',
     },
     estimatedCompletion: 'Tempo completamento stimato',
+    pauseIn: '{{time}} alla pausa · {{eta}}',
+    pauseAtLayer: 'Pausa al layer {{layer}}',
+    nextPauseAtLayer: 'Prossima pausa aggiunta nello slicer, al layer {{layer}}',
     plateNumber: 'Piastra {{number}}',
     slotOptions: 'Opzioni slot',
     // AMS hover popup
@@ -2149,6 +2152,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Soglia raffreddamento piatto',
     bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa',
+    pauseWarningMinutes: 'Preavviso pausa',
+    pauseWarningMinutesDescription: 'Minuti prima di una pausa aggiunta nello slicer in cui inviare la notifica "Pausa in arrivo"',
     userNotificationsEnabled: 'Notifiche utente',
     userNotificationsEnabledDescription: "Abilita il menu notifiche utente e le notifiche e-mail per gli eventi di stampa. Richiede l'autenticazione avanzata.",
     userNotificationsDisabledHint: "Abilita l'autenticazione avanzata per usare le notifiche utente.",
@@ -6666,6 +6671,9 @@ export default {
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
     firstLayerCompleteLabel: 'Primo strato completato',
     firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
+    pauseUpcomingLabel: 'Pausa in arrivo',
+    pauseUpcomingDescription: 'Preavviso prima di una pausa aggiunta nello slicer',
+    pauseUpcomingBadge: 'Pausa',
     connectedApps: 'App collegate',
     appMessages: 'Messaggi dalle app collegate',
     appMessagesDescription: 'Recapita i messaggi che altre applicazioni inviano tramite Bambuddy, ad es. Bambuddy Orders',

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

@@ -909,6 +909,9 @@ export default {
       openSettings: '設定を開く',
     },
     estimatedCompletion: '完了予定時刻',
+    pauseIn: '一時停止まで{{time}} · {{eta}}',
+    pauseAtLayer: 'レイヤー{{layer}}で一時停止',
+    nextPauseAtLayer: 'スライサーで追加した次の一時停止(レイヤー{{layer}})',
     plateNumber: 'プレート {{number}}',
     slotOptions: 'スロットオプション',
     // AMS hover popup
@@ -2193,6 +2196,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'ベッド冷却しきい値',
     bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度',
+    pauseWarningMinutes: '一時停止の事前通知',
+    pauseWarningMinutesDescription: 'スライサーで追加した一時停止の何分前に「まもなく一時停止」通知を送るか',
     userNotificationsEnabled: 'ユーザー通知',
     userNotificationsEnabledDescription: 'ユーザー通知メニューと印刷ジョブイベントのメール通知を有効にします。高度な認証が必要です。',
     userNotificationsDisabledHint: 'ユーザー通知を使用するには高度な認証を有効にしてください。',
@@ -6680,6 +6685,9 @@ export default {
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
     firstLayerCompleteLabel: '第1層完了',
     firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
+    pauseUpcomingLabel: 'まもなく一時停止',
+    pauseUpcomingDescription: 'スライサーで追加した一時停止の前に通知',
+    pauseUpcomingBadge: '一時停止',
     connectedApps: '連携アプリ',
     appMessages: '連携アプリからのメッセージ',
     appMessagesDescription: '他のアプリケーションが Bambuddy 経由で送信するメッセージを配信します(例: Bambuddy Orders)',

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

@@ -865,6 +865,9 @@ export default {
       openSettings: '설정 열기'
     },
     estimatedCompletion: '예상 완료 시간',
+    pauseIn: '일시정지까지 {{time}} · {{eta}}',
+    pauseAtLayer: '레이어 {{layer}}에서 일시정지',
+    nextPauseAtLayer: '슬라이서에서 추가한 다음 일시정지, 레이어 {{layer}}',
     plateNumber: '플레이트 {{number}}',
     slotOptions: '슬롯 옵션',
     amsPopup: {
@@ -2100,6 +2103,8 @@ export default {
     updateAvailable: '업데이트 가능',
     bedCooledThreshold: '베드 냉각 임계값',
     bedCooledThresholdDescription: '인쇄 후 베드가 냉각된 것으로 간주되는 온도',
+    pauseWarningMinutes: '일시정지 사전 알림',
+    pauseWarningMinutesDescription: '슬라이서에서 추가한 일시정지 몇 분 전에 "곧 일시정지" 알림을 보낼지',
     userNotificationsEnabled: '사용자 알림',
     userNotificationsEnabledDescription: '사용자 알림 메뉴 및 인쇄 작업 이벤트에 대한 이메일 알림을 활성화합니다. 고급 인증이 필요합니다.',
     userNotificationsDisabledHint: '사용자 알림을 사용하려면 고급 인증을 활성화하세요.',
@@ -6399,6 +6404,9 @@ export default {
     bedCooledDescription: '인쇄 후 베드가 임계값 이하로 냉각됨',
     firstLayerCompleteLabel: '첫 번째 레이어 완료',
     firstLayerCompleteDescription: '첫 번째 레이어 완료 시 스냅샷과 함께 알림',
+    pauseUpcomingLabel: '곧 일시정지',
+    pauseUpcomingDescription: '슬라이서에서 추가한 일시정지 전에 알림',
+    pauseUpcomingBadge: '일시정지',
     connectedApps: '연결된 앱',
     appMessages: '연결된 앱의 메시지',
     appMessagesDescription: '다른 애플리케이션이 Bambuddy를 통해 보내는 메시지를 전달합니다(예: Bambuddy Orders)',

+ 8 - 0
frontend/src/i18n/locales/nl.ts

@@ -915,6 +915,9 @@ export default {
       openSettings: 'Instellingen openen',
     },
     estimatedCompletion: 'Geschatte voltooiingstijd',
+    pauseIn: '{{time}} tot pauze · {{eta}}',
+    pauseAtLayer: 'Pauze bij laag {{layer}}',
+    nextPauseAtLayer: 'Volgende in de slicer toegevoegde pauze, bij laag {{layer}}',
     plateNumber: 'Plaat {{number}}',
     slotOptions: 'Sleufopties',
     // AMS hover popup
@@ -2214,6 +2217,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Drempel voor afgekoeld bed',
     bedCooledThresholdDescription: 'Temperatuur waaronder het bed na een afdruk als afgekoeld wordt beschouwd',
+    pauseWarningMinutes: 'Pauzewaarschuwing',
+    pauseWarningMinutesDescription: 'Minuten voor een in de slicer toegevoegde pauze waarop de melding "Pauze komt eraan" wordt verstuurd',
     userNotificationsEnabled: 'Gebruikersmeldingen',
     userNotificationsEnabledDescription: 'Schakel het gebruikersmeldingenmenu en e-mailmeldingen voor afdrukgebeurtenissen in. Vereist Geavanceerde authenticatie.',
     userNotificationsDisabledHint: 'Schakel Geavanceerde authenticatie in om gebruikersmeldingen te gebruiken.',
@@ -6730,6 +6735,9 @@ export default {
     bedCooledDescription: 'Bed na afdruk afgekoeld tot onder drempel',
     firstLayerCompleteLabel: 'Eerste laag voltooid',
     firstLayerCompleteDescription: 'Melden met momentopname wanneer de eerste laag is voltooid',
+    pauseUpcomingLabel: 'Pauze komt eraan',
+    pauseUpcomingDescription: 'Waarschuwing voor een in de slicer toegevoegde pauze',
+    pauseUpcomingBadge: 'Pauze',
     connectedApps: 'Gekoppelde apps',
     appMessages: 'Berichten van gekoppelde apps',
     appMessagesDescription: 'Berichten bezorgen die andere toepassingen via Bambuddy versturen, bijv. Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: 'Abrir configurações',
     },
     estimatedCompletion: 'Tempo estimado de conclusão',
+    pauseIn: '{{time}} até a pausa · {{eta}}',
+    pauseAtLayer: 'Pausa na camada {{layer}}',
+    nextPauseAtLayer: 'Próxima pausa adicionada no fatiador, na camada {{layer}}',
     plateNumber: 'Placa {{number}}',
     slotOptions: 'Opções de slot',
     // AMS hover popup
@@ -2149,6 +2152,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Limite de Resfriamento da Cama',
     bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão',
+    pauseWarningMinutes: 'Aviso de pausa',
+    pauseWarningMinutesDescription: 'Minutos antes de uma pausa adicionada no fatiador para enviar a notificação "Pausa chegando"',
     userNotificationsEnabled: 'Notificações do Usuário',
     userNotificationsEnabledDescription: 'Ativa o menu de notificações do usuário e notificações por e-mail para eventos de impressão. Requer Autenticação Avançada.',
     userNotificationsDisabledHint: 'Ative a Autenticação Avançada para usar as notificações do usuário.',
@@ -6666,6 +6671,9 @@ export default {
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
     firstLayerCompleteLabel: 'Primeira camada concluída',
     firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
+    pauseUpcomingLabel: 'Pausa chegando',
+    pauseUpcomingDescription: 'Aviso antes de uma pausa adicionada no fatiador',
+    pauseUpcomingBadge: 'Pausa',
     connectedApps: 'Aplicativos conectados',
     appMessages: 'Mensagens de aplicativos conectados',
     appMessagesDescription: 'Entregar mensagens que outros aplicativos enviam pelo Bambuddy, p. ex. Bambuddy Orders',

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

@@ -870,6 +870,9 @@ export default {
       openSettings: "Открыть настройки",
     },
     estimatedCompletion: "Расчётное время завершения",
+    pauseIn: "{{time}} до паузы · {{eta}}",
+    pauseAtLayer: "Пауза на слое {{layer}}",
+    nextPauseAtLayer: "Следующая пауза, добавленная в слайсере, на слое {{layer}}",
     plateNumber: "Пластина {{number}}",
     slotOptions: "Параметры слота",
     amsPopup: {
@@ -2097,6 +2100,8 @@ export default {
     updateAvailable: "Доступно обновление",
     bedCooledThreshold: "Порог остывания стола",
     bedCooledThresholdDescription: "Температура, ниже которой стол после печати считается остывшим",
+    pauseWarningMinutes: "Предупреждение о паузе",
+    pauseWarningMinutesDescription: "За сколько минут до паузы, добавленной в слайсере, отправлять уведомление \"Скоро пауза\"",
     userNotificationsEnabled: "Пользовательские уведомления",
     userNotificationsEnabledDescription: "Включить меню пользовательских и почтовых уведомлений о событиях печати. Требуется расширенная аутентификация.",
     userNotificationsDisabledHint: "Для пользовательских уведомлений включите расширенную аутентификацию.",
@@ -6386,6 +6391,9 @@ export default {
     bedCooledDescription: "После печати температура стола опустилась ниже заданного порога",
     firstLayerCompleteLabel: "Первый слой завершён",
     firstLayerCompleteDescription: "Уведомить со снимком после завершения первого слоя",
+    pauseUpcomingLabel: "Скоро пауза",
+    pauseUpcomingDescription: "Предупреждение перед паузой, добавленной в слайсере",
+    pauseUpcomingBadge: "Пауза",
     connectedApps: 'Подключённые приложения',
     appMessages: 'Сообщения подключённых приложений',
     appMessagesDescription: 'Доставлять сообщения, которые другие приложения отправляют через Bambuddy, например Bambuddy Orders',

+ 8 - 0
frontend/src/i18n/locales/sv.ts

@@ -915,6 +915,9 @@ export default {
       openSettings: 'Öppna inställningar',
     },
     estimatedCompletion: 'Beräknad färdigställningstid',
+    pauseIn: '{{time}} till paus · {{eta}}',
+    pauseAtLayer: 'Paus vid lager {{layer}}',
+    nextPauseAtLayer: 'Nästa paus tillagd i slicern, vid lager {{layer}}',
     plateNumber: 'Platta {{number}}',
     slotOptions: 'Fackalternativ',
     // AMS hover popup
@@ -2213,6 +2216,8 @@ export default {
     // Notifications
     bedCooledThreshold: 'Tröskelvärde för sval byggplatta',
     bedCooledThresholdDescription: 'Temperatur under vilken byggplattan anses sval efter en utskrift',
+    pauseWarningMinutes: 'Pausvarning',
+    pauseWarningMinutesDescription: 'Minuter före en paus tillagd i slicern då aviseringen "Paus närmar sig" skickas',
     userNotificationsEnabled: 'Användarnotiser',
     userNotificationsEnabledDescription: 'Aktivera menyn för användarnotiser och e-postnotiser för utskriftshändelser. Kräver avancerad autentisering.',
     userNotificationsDisabledHint: 'Aktivera avancerad autentisering för att använda användarnotiser.',
@@ -6728,6 +6733,9 @@ errors: {
     bedCooledDescription: 'Byggplattan kyld under tröskelvärdet efter utskrift',
     firstLayerCompleteLabel: 'Första skikt klart',
     firstLayerCompleteDescription: 'Notis med ögonblicksbild när första skiktet är klart',
+    pauseUpcomingLabel: 'Paus närmar sig',
+    pauseUpcomingDescription: 'Förvarning före en paus tillagd i slicern',
+    pauseUpcomingBadge: 'Paus',
     connectedApps: 'Anslutna appar',
     appMessages: 'Meddelanden från anslutna appar',
     appMessagesDescription: 'Leverera meddelanden som andra program skickar via Bambuddy, t.ex. Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: 'Ayarları aç',
     },
     estimatedCompletion: 'Tahmini tamamlanma süresi',
+    pauseIn: 'Duraklatmaya {{time}} · {{eta}}',
+    pauseAtLayer: '{{layer}}. katmanda duraklatma',
+    nextPauseAtLayer: 'Dilimleyicide eklenen sonraki duraklatma, {{layer}}. katmanda',
     plateNumber: 'Plaka {{number}}',
     slotOptions: 'Yuva seçenekleri',
     // AMS hover popup
@@ -2197,6 +2200,8 @@ export default {
     // Bildirimler
     bedCooledThreshold: 'Tabla Soğuma Eşiği',
     bedCooledThresholdDescription: 'Bir baskıdan sonra tablanın soğumuş kabul edildiği sıcaklık',
+    pauseWarningMinutes: 'Duraklatma uyarısı',
+    pauseWarningMinutesDescription: 'Dilimleyicide eklenen bir duraklatmadan kaç dakika önce "Duraklatma yaklaşıyor" bildiriminin gönderileceği',
     userNotificationsEnabled: 'Kullanıcı Bildirimleri',
     userNotificationsEnabledDescription: 'Baskı işi olayları için kullanıcı bildirim menüsünü ve e-posta bildirimlerini etkinleştir. Gelişmiş Kimlik Doğrulama gerektirir.',
     userNotificationsDisabledHint: 'Kullanıcı bildirimlerini kullanmak için Gelişmiş Kimlik Doğrulamayı etkinleştirin.',
@@ -6635,6 +6640,9 @@ export default {
     bedCooledDescription: 'Baskıdan sonra tabla eşiğin altına soğudu',
     firstLayerCompleteLabel: 'İlk Katman Tamamlandı',
     firstLayerCompleteDescription: 'İlk katman bittiğinde anlık görüntüyle bildir',
+    pauseUpcomingLabel: 'Duraklatma yaklaşıyor',
+    pauseUpcomingDescription: 'Dilimleyicide eklenen bir duraklatmadan önce uyarı',
+    pauseUpcomingBadge: 'Duraklatma',
     connectedApps: 'Bağlı uygulamalar',
     appMessages: 'Bağlı uygulamalardan gelen mesajlar',
     appMessagesDescription: 'Diğer uygulamaların Bambuddy üzerinden gönderdiği mesajları iletir, ör. Bambuddy Orders',

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

@@ -914,6 +914,9 @@ export default {
       openSettings: "Відкрити налаштування",
     },
     estimatedCompletion: "Орієнтовний час виконання",
+    pauseIn: "{{time}} до паузи · {{eta}}",
+    pauseAtLayer: "Пауза на шарі {{layer}}",
+    nextPauseAtLayer: "Наступна пауза, додана в слайсері, на шарі {{layer}}",
     plateNumber: "Пластина {{number}}",
     slotOptions: "Опції слота",
     // AMS hover popup
@@ -2212,6 +2215,8 @@ export default {
     // Notifications
     bedCooledThreshold: "Поріг охолодження столу",
     bedCooledThresholdDescription: "Температура, нижче якої стіл вважається охолодженим після друку",
+    pauseWarningMinutes: "Попередження про паузу",
+    pauseWarningMinutesDescription: "За скільки хвилин до паузи, доданої в слайсері, надсилати сповіщення \"Наближається пауза\"",
     userNotificationsEnabled: "Сповіщення користувача",
     userNotificationsEnabledDescription: "Увімкніть меню сповіщень користувача та сповіщення електронною поштою про події завдань друку. Потрібна розширена автентифікація.",
     userNotificationsDisabledHint: "Увімкніть розширену автентифікацію, щоб використовувати сповіщення користувачів.",
@@ -6720,6 +6725,9 @@ export default {
     bedCooledDescription: "Після друку стіл охолов нижче заданого порога",
     firstLayerCompleteLabel: "Перший шар завершено",
     firstLayerCompleteDescription: "Сповістити зі знімком після завершення першого шару",
+    pauseUpcomingLabel: "Наближається пауза",
+    pauseUpcomingDescription: "Попередження перед паузою, доданою в слайсері",
+    pauseUpcomingBadge: "Пауза",
     connectedApps: 'Підключені застосунки',
     appMessages: 'Повідомлення підключених застосунків',
     appMessagesDescription: 'Доставляти повідомлення, які інші застосунки надсилають через Bambuddy, наприклад Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: '打开设置',
     },
     estimatedCompletion: '预计完成时间',
+    pauseIn: '距暂停 {{time}} · {{eta}}',
+    pauseAtLayer: '在第 {{layer}} 层暂停',
+    nextPauseAtLayer: '切片软件中添加的下一个暂停,在第 {{layer}} 层',
     plateNumber: '板 {{number}}',
     slotOptions: '槽位选项',
     // AMS hover popup
@@ -2194,6 +2197,8 @@ export default {
     // Notifications
     bedCooledThreshold: '热床冷却阈值',
     bedCooledThresholdDescription: '打印后热床被视为已冷却的温度',
+    pauseWarningMinutes: '暂停提前提醒',
+    pauseWarningMinutesDescription: '在切片软件中添加的暂停前多少分钟发送“即将暂停”通知',
     userNotificationsEnabled: '用户通知',
     userNotificationsEnabledDescription: '启用用户通知菜单和打印任务事件的邮件通知。需要高级身份验证。',
     userNotificationsDisabledHint: '请启用高级身份验证以使用用户通知。',
@@ -6665,6 +6670,9 @@ export default {
     bedCooledDescription: '打印后热床温度降至阈值以下',
     firstLayerCompleteLabel: '首层打印完成',
     firstLayerCompleteDescription: '首层完成时发送带照片的通知',
+    pauseUpcomingLabel: '即将暂停',
+    pauseUpcomingDescription: '切片软件中添加的暂停前提醒',
+    pauseUpcomingBadge: '暂停',
     connectedApps: '已连接的应用',
     appMessages: '来自已连接应用的消息',
     appMessagesDescription: '投递其他应用通过 Bambuddy 发送的消息,例如 Bambuddy Orders',

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

@@ -910,6 +910,9 @@ export default {
       openSettings: '開啟設定',
     },
     estimatedCompletion: '預計完成時間',
+    pauseIn: '距暫停 {{time}} · {{eta}}',
+    pauseAtLayer: '在第 {{layer}} 層暫停',
+    nextPauseAtLayer: '切片軟體中加入的下一個暫停,在第 {{layer}} 層',
     plateNumber: '板 {{number}}',
     slotOptions: '槽位選項',
     // AMS hover popup
@@ -2194,6 +2197,8 @@ export default {
     // Notifications
     bedCooledThreshold: '熱床冷卻閾值',
     bedCooledThresholdDescription: '列印後熱床被視為已冷卻的溫度',
+    pauseWarningMinutes: '暫停提前提醒',
+    pauseWarningMinutesDescription: '在切片軟體中加入的暫停前多少分鐘傳送「即將暫停」通知',
     userNotificationsEnabled: '使用者通知',
     userNotificationsEnabledDescription: '啟用使用者通知選單和列印任務事件的郵件通知。需要進階身份驗證。',
     userNotificationsDisabledHint: '請啟用進階身份驗證以使用使用者通知。',
@@ -6665,6 +6670,9 @@ export default {
     bedCooledDescription: '列印後熱床溫度降至閾值以下',
     firstLayerCompleteLabel: '首層列印完成',
     firstLayerCompleteDescription: '首層完成時傳送帶照片的通知',
+    pauseUpcomingLabel: '即將暫停',
+    pauseUpcomingDescription: '切片軟體中加入的暫停前提醒',
+    pauseUpcomingBadge: '暫停',
     connectedApps: '已連線的應用程式',
     appMessages: '來自已連線應用程式的訊息',
     appMessagesDescription: '傳遞其他應用程式透過 Bambuddy 傳送的訊息,例如 Bambuddy Orders',

+ 19 - 0
frontend/src/pages/PrintersPage.tsx

@@ -4462,6 +4462,25 @@ function PrinterCard({
                                     </span>
                                   </>
                                 )}
+                                {/* #2984: the next pause the slicer put into the print, so
+                                    nobody has to watch the layer count to be there for it. */}
+                                {status.next_pause && (
+                                  <span
+                                    className="flex min-w-0 items-center gap-1 text-blue-400"
+                                    title={t('printers.nextPauseAtLayer', { layer: status.next_pause.layer })}
+                                    data-testid="next-pause"
+                                  >
+                                    <Pause className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)] shrink-0" />
+                                    <span className="truncate">
+                                      {status.next_pause.minutes != null
+                                        ? t('printers.pauseIn', {
+                                          time: formatDuration(Math.max(status.next_pause.minutes, 1) * 60),
+                                          eta: formatETA(status.next_pause.minutes, timeFormat, t),
+                                        })
+                                        : t('printers.pauseAtLayer', { layer: status.next_pause.layer })}
+                                    </span>
+                                  </span>
+                                )}
                                 {status.layer_num != null && status.total_layers != null && status.total_layers > 0 && (
                                   <span className="flex items-center gap-1">
                                     <Layers className="w-[var(--pc-i3,0.75rem)] h-[var(--pc-i3,0.75rem)]" />

+ 37 - 0
frontend/src/pages/SettingsPage.tsx

@@ -246,6 +246,15 @@ function SecretHint({ name, value, reenter }: { name: string; value: unknown; re
   );
 }
 
+/**
+ * The pause heads-up lead time as the server takes it: whole minutes, 1-120,
+ * the default for an emptied field (#2984). Compared clamped too, so a value
+ * the server stored clamped does not read as an unsaved change forever.
+ */
+function clampPauseWarningMinutes(minutes: number | undefined): number {
+  return Math.min(120, Math.max(1, Math.round(minutes || 5)));
+}
+
 export function SettingsPage() {
   const [cameraTokenRevision, setCameraTokenRevision] = useState(0);
   const queryClient = useQueryClient();
@@ -1272,6 +1281,7 @@ export function SettingsPage() {
       (baseline.announcements_all_users ?? false) !== (localSettings.announcements_all_users ?? false) ||
       (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
       (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
+      clampPauseWarningMinutes(baseline.pause_warning_minutes) !== clampPauseWarningMinutes(localSettings.pause_warning_minutes) ||
       baseline.ams_humidity_good !== localSettings.ams_humidity_good ||
       baseline.ams_humidity_fair !== localSettings.ams_humidity_fair ||
       baseline.ams_temp_good !== localSettings.ams_temp_good ||
@@ -1400,6 +1410,7 @@ export function SettingsPage() {
         announcements_all_users: localSettings.announcements_all_users ?? false,
         local_login_enabled: localSettings.local_login_enabled,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
+        pause_warning_minutes: clampPauseWarningMinutes(localSettings.pause_warning_minutes),
         ams_humidity_good: localSettings.ams_humidity_good,
         ams_humidity_fair: localSettings.ams_humidity_fair,
         ams_temp_good: localSettings.ams_temp_good,
@@ -4099,6 +4110,32 @@ export function SettingsPage() {
               </CardContent>
             </Card>
 
+            {/* Pause heads-up lead time (#2984) */}
+            <Card className="mb-4">
+              <CardContent className="py-3">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white text-sm font-medium">{t('settings.pauseWarningMinutes')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.pauseWarningMinutesDescription')}</p>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <input
+                      type="number"
+                      min={1}
+                      max={120}
+                      step={1}
+                      value={localSettings.pause_warning_minutes ?? 5}
+                      onChange={(e) => updateSetting('pause_warning_minutes', Number(e.target.value))}
+                      onBlur={() => updateSetting('pause_warning_minutes', clampPauseWarningMinutes(localSettings.pause_warning_minutes))}
+                      aria-label={t('settings.pauseWarningMinutes')}
+                      className="w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-bambu-gray">{t('common.minutes')}</span>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
             {/* User Notifications Toggle */}
             <Card className="mb-4">
               <CardContent className="py-3">

文件差異過大導致無法顯示
+ 0 - 0
static/assets/ArchivesPage-DK8Jd5wQ.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CamWallPage-BUC3zHkP.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/CameraPage-CbtcgUxC.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/FileManagerPage-BxfQtfJp.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/InventoryPage-QEg8BksK.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/Layout-DfNbRVI6.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/Layout-_oJ-ykLc.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ModelSourcesPage-VPvkCpjn.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PhotoGalleryModal-COMGC-_m.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/PrintersPage-CCCkF3oJ.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SettingsPage-3-NATkhb.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SettingsPage-Cw_y12mU.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/SliceModal-CWxvqsV3.js


文件差異過大導致無法顯示
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BoTM3ff6.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/de-B8d4u1gC.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/de-BikatwOZ.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/es-CR0SSq8S.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/es-L-2yOMsc.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/fr-BOnzRK4I.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/fr-DgNCXgNC.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/index-C8_xNeLP.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/it-BFxneBzB.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/it-qEgZ1gfe.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ja-Cl5BNUjU.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ko-CGa-YoWG.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ko-CyFKZwiF.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/locationSensorDefaults-CkYZEEPm.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/nl-BdL4-Fiw.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/nl-DfaOqiW3.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/pt-BR-DRmpSEwF.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/pt-BR-vwQWjDRH.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ru-CF-c9YSH.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/ru-DDjN_NDo.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/sv-DGaQLCku.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/sv-DoYSwBHG.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/tr-BzDsH2JK.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/tr-DGxBjCtq.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/uk-CclO3Zoh.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/uk-D55DRuhE.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-CN-BdhRNjdm.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-CN-Duqvqkcg.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-TW-B6LTXDxh.js


文件差異過大導致無法顯示
+ 0 - 0
static/assets/zh-TW-DPD4-jvL.js


+ 2 - 2
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-ZARBsKKX.js"></script>
+    <script type="module" crossorigin src="/assets/index-C8_xNeLP.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-DSw2TdH4.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-PmRWqOqI.js">
@@ -122,7 +122,7 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-7yzLvEqw.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-CpiTIx_j.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-jD6ObGRm.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-DfNbRVI6.js">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-_oJ-ykLc.js">
     <link rel="stylesheet" crossorigin href="/assets/index-DAazpymq.css">
   </head>
   <body>

部分文件因文件數量過多而無法顯示