Browse Source

Add native Notify! alerts, Live Activities and Lock Screen widgets (#3265)

simplytoast1 16 hours ago
parent
commit
b42d39ffcc
100 changed files with 5426 additions and 67 deletions
  1. 82 2
      backend/app/api/routes/notifications.py
  2. 2 0
      backend/app/core/database.py
  3. 19 0
      backend/app/main.py
  4. 4 0
      backend/app/models/__init__.py
  5. 34 0
      backend/app/models/notification_live_activity.py
  6. 27 0
      backend/app/models/notification_lock_screen_widget.py
  7. 1 0
      backend/app/schemas/notification.py
  8. 1 0
      backend/app/services/bambu_mqtt.py
  9. 48 0
      backend/app/services/notification_service.py
  10. 312 0
      backend/app/services/notify_client.py
  11. 752 0
      backend/app/services/notify_live_activities.py
  12. 456 0
      backend/app/services/notify_widgets.py
  13. 192 0
      backend/app/services/notify_worker.py
  14. 203 0
      backend/tests/integration/test_notify_provider_api.py
  15. 317 0
      backend/tests/unit/services/test_notify_client.py
  16. 896 0
      backend/tests/unit/services/test_notify_live_activities.py
  17. 203 0
      backend/tests/unit/services/test_notify_widget_client.py
  18. 790 0
      backend/tests/unit/services/test_notify_widgets.py
  19. 72 0
      backend/tests/unit/test_notify_callback_isolation.py
  20. 1 1
      frontend/scripts/check-i18n-parity.mjs
  21. 306 0
      frontend/src/__tests__/components/AddNotificationModal.test.tsx
  22. 18 1
      frontend/src/api/client.ts
  23. 224 52
      frontend/src/components/AddNotificationModal.tsx
  24. 13 3
      frontend/src/components/NotificationProviderCard.tsx
  25. 30 0
      frontend/src/i18n/locales/de.ts
  26. 30 0
      frontend/src/i18n/locales/en.ts
  27. 30 0
      frontend/src/i18n/locales/es.ts
  28. 30 0
      frontend/src/i18n/locales/fr.ts
  29. 30 0
      frontend/src/i18n/locales/it.ts
  30. 30 0
      frontend/src/i18n/locales/ja.ts
  31. 30 0
      frontend/src/i18n/locales/ko.ts
  32. 30 0
      frontend/src/i18n/locales/nl.ts
  33. 30 0
      frontend/src/i18n/locales/pt-BR.ts
  34. 30 0
      frontend/src/i18n/locales/ru.ts
  35. 30 0
      frontend/src/i18n/locales/sv.ts
  36. 30 0
      frontend/src/i18n/locales/tr.ts
  37. 30 0
      frontend/src/i18n/locales/uk.ts
  38. 30 0
      frontend/src/i18n/locales/zh-CN.ts
  39. 30 0
      frontend/src/i18n/locales/zh-TW.ts
  40. 3 0
      frontend/src/utils/notify.ts
  41. 0 0
      static/assets/AdditionalSection-9uxxm13d.js
  42. 0 0
      static/assets/AmsUnitCard-CeSWF1rR.js
  43. 0 0
      static/assets/ArchivesPage-BrcJqnr-.js
  44. 0 0
      static/assets/AreaChart-DJpIJXxF.js
  45. 0 0
      static/assets/AssignToAmsModal-dWQ_DHhy.js
  46. 0 1
      static/assets/Button-DDr6eAFy.js
  47. 0 0
      static/assets/CamWallPage-BDGEzKQE.js
  48. 0 0
      static/assets/CameraDiagnoseModal-BwHHipUP.js
  49. 0 0
      static/assets/CameraPage-D1WovNYe.js
  50. 0 0
      static/assets/CameraWall-D-W28jK-.js
  51. 0 1
      static/assets/Card-Bzc64K35.js
  52. 0 0
      static/assets/CartesianChart-BAcHzPFb.js
  53. 0 0
      static/assets/ColumnConfigModal-BBpKz9m6.js
  54. 0 0
      static/assets/ConfigureAmsSlotModal-BgpDvjjX.js
  55. 0 0
      static/assets/ConnectAuthorizePage-CIbOgIV8.js
  56. 0 0
      static/assets/ContextMenu-CwroFwVJ.js
  57. 0 0
      static/assets/CopyButton-C5In5weo.js
  58. 0 0
      static/assets/ErrorBarContext-CcijMMkA.js
  59. 0 0
      static/assets/ExternalLinkPage-BN4q7Gs-.js
  60. 0 1
      static/assets/FileManagerPage-BASV0fq8.js
  61. 0 0
      static/assets/FileUploadModal-Dee1GxH_.js
  62. 0 0
      static/assets/FinancePage-D-k9CqTS.js
  63. 0 0
      static/assets/GCodeViewerPage-BZpj3rht.js
  64. 0 0
      static/assets/GcodeToolpathViewer-Cg6UnTDw.js
  65. 0 0
      static/assets/GroupEditPage-CMQ5Sk5q.js
  66. 0 0
      static/assets/ImagePreviewModal-NAGAMcx7.js
  67. 0 0
      static/assets/InventoryPage-o24Ni0S2.js
  68. 0 0
      static/assets/LibraryTrashPage-zY9GRWm9.js
  69. 0 0
      static/assets/LoginPage-DXBBHPDD.js
  70. 0 0
      static/assets/MaintenancePage-B6wtSJnR.js
  71. 0 0
      static/assets/ModelSourcesPage-CI9pVpjC.js
  72. 0 0
      static/assets/NotificationsPage-DTBdRJe1.js
  73. 0 0
      static/assets/OrbitControls-Bbqf07B-.js
  74. 0 1
      static/assets/OverlayFrame-fLmufrTC.js
  75. 0 1
      static/assets/PdfPreviewModal-gJCfcyUW.js
  76. 0 0
      static/assets/PhotoGalleryModal-BPFizk3N.js
  77. 0 0
      static/assets/PrinterLocationsPage-Dj61FxW8.js
  78. 0 0
      static/assets/PrintersPage-CLG7I78P.js
  79. 0 0
      static/assets/ProfilesPage-FzXKlAbc.js
  80. 0 0
      static/assets/ProjectDetailPage-Cg8wQL8j.js
  81. 0 0
      static/assets/ProjectsPage-CBeO6wA6.js
  82. 0 0
      static/assets/QueryClientProvider-Ce8_sN3w.js
  83. 0 0
      static/assets/QueuePage-NR_JuCMy.js
  84. 0 0
      static/assets/ReferenceLine-clJprXoH.js
  85. 0 0
      static/assets/RichTextEditor-CsfJOD7X.js
  86. 0 0
      static/assets/SettingsPage-DCvPzv7P.js
  87. 0 0
      static/assets/SetupPage-D-ymjFl3.js
  88. 0 0
      static/assets/SliceModal-YlJTMEj6.js
  89. 0 0
      static/assets/SpoolBuddyAmsPage-BzUFeU61.js
  90. 0 0
      static/assets/SpoolBuddyCalibrationPage-C9Vz9h1f.js
  91. 0 0
      static/assets/SpoolBuddyDashboard-0Mnwe1xw.js
  92. 0 0
      static/assets/SpoolBuddyInventoryPage-FFmQ5AMP.js
  93. 0 1
      static/assets/SpoolBuddySettingsPage-dHNHNIW3.js
  94. 0 0
      static/assets/SpoolBuddyWriteTagPage-CQovcCoW.js
  95. 0 1
      static/assets/SpoolIcon-DE8TbEec.js
  96. 0 1
      static/assets/SpreadsheetPreviewModal-KGR-XjV_.js
  97. 0 0
      static/assets/StatsPage-E-fXkkLD.js
  98. 0 0
      static/assets/StreamOverlayPage-CA9KpVK8.css
  99. 0 0
      static/assets/StreamOverlayPage-H9AqKpdI.js
  100. 0 0
      static/assets/SystemInfoPage-J8sIDAyN.js

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

@@ -8,13 +8,14 @@ from datetime import datetime, timedelta, timezone
 
 from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import FileResponse
-from sqlalchemy import delete, desc, func, select
+from sqlalchemy import delete, desc, func, select, update
 from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.notification import NotificationLog, NotificationProvider
+from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.user import User
 from backend.app.schemas.notification import (
     AppMessage,
@@ -29,6 +30,9 @@ from backend.app.schemas.notification import (
     NotificationTestResponse,
 )
 from backend.app.services.notification_service import notification_service
+from backend.app.services.notify_client import NotifyError, notify_credentials
+from backend.app.services.notify_live_activities import notify_live_activities
+from backend.app.services.notify_widgets import notify_widgets
 from backend.app.services.telegram_reactions import telegram_reaction_poller
 from backend.app.utils.notification_photos import find_notification_photo
 
@@ -37,6 +41,15 @@ logger = logging.getLogger(__name__)
 router = APIRouter(prefix="/notifications", tags=["notifications"])
 
 
+def _notify_providers_changed() -> None:
+    """Wake optional workers after a provider edit, without waiting for their HTTP work."""
+    for worker in (notify_live_activities, notify_widgets):
+        try:
+            worker.providers_changed()
+        except Exception:
+            logger.exception("Could not refresh Notify worker configuration")
+
+
 async def _resync_reaction_poller():
     """Start/stop Telegram reaction polls after a provider changed (#3046).
 
@@ -153,6 +166,11 @@ async def create_notification_provider(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
 ):
     """Create a new notification provider."""
+    if provider_data.provider_type.value == "notify":
+        try:
+            notify_credentials(provider_data.config)
+        except NotifyError as exc:
+            raise HTTPException(status_code=422, detail=str(exc)) from None
     provider = NotificationProvider(
         name=provider_data.name,
         provider_type=provider_data.provider_type.value,
@@ -221,6 +239,8 @@ async def create_notification_provider(
     await db.refresh(provider)
 
     logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
+    if provider.provider_type == "notify":
+        _notify_providers_changed()
     await _resync_reaction_poller()
 
     return _provider_to_dict(provider)
@@ -538,9 +558,23 @@ async def update_notification_provider(
     if not provider:
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
+    old_config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+    old_type = provider.provider_type
+
     # Update only provided fields
     update_dict = update_data.model_dump(exclude_unset=True)
 
+    effective_type = update_dict.get("provider_type") or provider.provider_type
+    effective_type = getattr(effective_type, "value", effective_type)
+    if effective_type == "notify":
+        try:
+            effective_config = update_dict.get("config", old_config)
+            if not isinstance(effective_config, dict):
+                raise NotifyError("Notify! configuration must be an object")
+            notify_credentials(effective_config)
+        except NotifyError as exc:
+            raise HTTPException(status_code=422, detail=str(exc)) from None
+
     for key, value in update_dict.items():
         if key == "config" and value is not None:
             setattr(provider, key, json.dumps(value))
@@ -549,13 +583,48 @@ async def update_notification_provider(
         else:
             setattr(provider, key, value)
 
+    if old_type == "notify":
+        effective_config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        remove_widgets = (
+            provider.provider_type != "notify"
+            or not provider.enabled
+            or effective_config.get("lock_screen_widgets") is not True
+        )
+        if remove_widgets or provider.printer_id is not None:
+            condition = NotificationLockScreenWidget.provider_id == provider_id
+            if not remove_widgets:
+                condition = condition & (NotificationLockScreenWidget.printer_id != provider.printer_id)
+            # Persist opt-out before returning, even if the user immediately
+            # opts back in. The worker finishes cleanup before creating anew.
+            await db.execute(
+                update(NotificationLockScreenWidget)
+                .where(condition)
+                .values(
+                    state="deleting",
+                    failures=0,
+                    next_attempt_at=None,
+                )
+            )
+
     await db.commit()
     await db.refresh(provider)
 
+    response = _provider_to_dict(provider)
+    new_config = response["config"]
+    changed = old_type != provider.provider_type or any(
+        old_config.get(key) != new_config.get(key) for key in ("device_id", "token")
+    )
+    # Refresh opened a new read transaction; close it before remote cleanup.
+    await db.commit()
+    if old_type == "notify" and changed:
+        await notify_live_activities.schedule_cleanup(provider_id, old_config)
+        await notify_widgets.schedule_cleanup(provider_id, old_config)
+    if old_type == "notify" or provider.provider_type == "notify":
+        _notify_providers_changed()
     logger.info("Updated notification provider: %s", provider.name)
     await _resync_reaction_poller()
 
-    return _provider_to_dict(provider)
+    return response
 
 
 @router.delete("/{provider_id}")
@@ -572,9 +641,20 @@ async def delete_notification_provider(
         raise HTTPException(status_code=404, detail="Notification provider not found")
 
     name = provider.name
+    if provider.provider_type == "notify":
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        # Retire this provider and capture owned IDs before FK cascade removes
+        # them. Queue remote cleanup; HTTP must never delay this request.
+        provider.enabled = False
+        await db.commit()
+        await notify_live_activities.schedule_cleanup(provider_id, config)
+        await notify_widgets.schedule_cleanup(provider_id, config)
     await db.delete(provider)
     await db.commit()
 
+    if provider.provider_type == "notify":
+        _notify_providers_changed()
+
     logger.info("Deleted notification provider: %s", name)
     await _resync_reaction_poller()
 

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

@@ -339,6 +339,8 @@ async def init_db():
         long_lived_token,
         maintenance,
         notification,
+        notification_live_activity,
+        notification_lock_screen_widget,
         notification_template,
         oidc_provider,
         orca_base_cache,

+ 19 - 0
backend/app/main.py

@@ -122,6 +122,8 @@ from backend.app.services.location_ha_sensor_manager import location_ha_sensor_m
 from backend.app.services.mqtt_relay import mqtt_relay
 from backend.app.services.mqtt_smart_plug import mqtt_smart_plug_service
 from backend.app.services.notification_service import notification_service
+from backend.app.services.notify_live_activities import notify_live_activities
+from backend.app.services.notify_widgets import notify_widgets
 from backend.app.services.obico_detection import obico_detection_service
 from backend.app.services.print_cost_estimate import plate_scoped_run_estimate as _plate_scoped_run_estimate
 from backend.app.services.print_scheduler import scheduler as print_scheduler
@@ -1615,6 +1617,10 @@ def _progress_milestone_to_notify(printer_id: int, state: PrinterState) -> int |
 
 async def on_printer_status_change(printer_id: int, state: PrinterState):
     """Handle printer status changes - broadcast via WebSocket."""
+    try:
+        notify_live_activities.observe(printer_id, state)
+    except Exception:
+        logging.getLogger(__name__).exception("Notify Live Activity status hook 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
@@ -4062,6 +4068,11 @@ async def on_print_start(printer_id: int, data: dict):
 
     logger.info("[CALLBACK] on_print_start called for printer %s, data keys: %s", printer_id, list(data.keys()))
 
+    try:
+        notify_live_activities.print_started(printer_id, printer_manager.get_status(printer_id), data)
+    except Exception:
+        logger.exception("Notify Live Activity start hook failed for printer %s", printer_id)
+
     # Clear any stale user-stopped flag from previous print cycles
     _user_stopped_printers.discard(printer_id)
     # A new print starts its milestones from zero (#3211). The status path only
@@ -7435,6 +7446,10 @@ async def on_print_complete(printer_id: int, data: dict):
     # which auto-dispatched the next queued print onto a fouled bed two seconds
     # after a touchscreen-abort (#1171). Persisted to DB so the gate survives
     # Auto Off power cycles and Bambuddy restarts.
+    try:
+        notify_live_activities.print_finished(printer_id, data)
+    except Exception:
+        logger.exception("Notify Live Activity completion hook failed for printer %s", printer_id)
     _final_status = data.get("status", "completed")
     if _final_status in ("completed", "failed", "aborted", "cancelled"):
         printer_manager.set_awaiting_plate_clear(printer_id, True)
@@ -10290,6 +10305,8 @@ async def lifespan(app: FastAPI):
 
     # Start the notification digest scheduler
     notification_service.start_digest_scheduler()
+    notify_live_activities.start()
+    notify_widgets.start()
 
     # Start the Telegram reaction pollers (#3046), one per bot token used by a
     # provider in reactions/both mode; the notification routes resync them.
@@ -10384,6 +10401,8 @@ async def lifespan(app: FastAPI):
     ha_sensor_manager.stop()
     location_ha_sensor_manager.stop()
     notification_service.stop_digest_scheduler()
+    await notify_live_activities.close()
+    await notify_widgets.close()
     await telegram_reaction_poller.aclose()
     github_backup_service.stop_scheduler()
     local_backup_service.stop_scheduler()

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

@@ -17,6 +17,8 @@ from backend.app.models.location_ha_sensor import LocationHASensor
 from backend.app.models.long_lived_token import LongLivedToken
 from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance
 from backend.app.models.notification import NotificationLog
+from backend.app.models.notification_live_activity import NotificationLiveActivity
+from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.oidc_provider import OIDCProvider, UserOIDCLink
 from backend.app.models.orca_base_cache import OrcaBaseProfile
@@ -61,6 +63,8 @@ __all__ = [
     "KProfileNote",
     "NotificationTemplate",
     "NotificationLog",
+    "NotificationLiveActivity",
+    "NotificationLockScreenWidget",
     "Project",
     "APIKey",
     "AMSSensorHistory",

+ 34 - 0
backend/app/models/notification_live_activity.py

@@ -0,0 +1,34 @@
+"""Durable ownership of Notify! tiles, including per-print dismissal tombstones."""
+
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+
+from backend.app.core.database import Base
+
+
+class NotificationLiveActivity(Base):
+    __tablename__ = "notification_live_activities"
+    __table_args__ = (UniqueConstraint("provider_id", "printer_id", "print_key"),)
+
+    id = Column(Integer, primary_key=True)
+    provider_id = Column(
+        Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+    # Keep ownership after printer deletion until the worker ends the remote tile.
+    printer_id = Column(Integer, nullable=False, index=True)
+    print_key = Column(String(200), nullable=False)
+    job_key = Column(String(160), nullable=True)
+    print_name = Column(String(255), nullable=False, default="")
+    eta_seconds = Column(Integer, nullable=True)
+    eta_deadline = Column(DateTime, nullable=True)
+    credential_key = Column(String(64), nullable=False)
+    activity_id = Column(String(16), nullable=True)
+    state = Column(String(20), nullable=False, default="pending")
+    end_reason = Column(String(40), nullable=True)
+    failures = Column(Integer, nullable=False, default=0)
+    content = Column(Text, nullable=False, default="{}")
+    expires_at = Column(DateTime, nullable=True)
+    next_attempt_at = Column(DateTime, nullable=True)
+    last_sent_at = Column(DateTime, nullable=True)
+    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

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

@@ -0,0 +1,27 @@
+"""Durable ownership of one persistent Notify! widget per printer/provider."""
+
+from datetime import datetime
+
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
+
+from backend.app.core.database import Base
+
+
+class NotificationLockScreenWidget(Base):
+    __tablename__ = "notification_lock_screen_widgets"
+    __table_args__ = (UniqueConstraint("provider_id", "printer_id"),)
+
+    id = Column(Integer, primary_key=True)
+    provider_id = Column(
+        Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False, index=True
+    )
+    # A removed printer must leave ownership intact until the remote DELETE succeeds.
+    printer_id = Column(Integer, nullable=False, index=True)
+    credential_key = Column(String(64), nullable=False)
+    widget_id = Column(String(16), nullable=True)
+    state = Column(String(20), nullable=False, default="pending")
+    content = Column(Text, nullable=False, default="{}")
+    failures = Column(Integer, nullable=False, default=0)
+    next_attempt_at = Column(DateTime, nullable=True)
+    last_sent_at = Column(DateTime, nullable=True)
+    created_at = Column(DateTime, nullable=False, default=datetime.utcnow)

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

@@ -21,6 +21,7 @@ class ProviderType(StrEnum):
     HOMEASSISTANT = "homeassistant"
     BARK = "bark"
     GOTIFY = "gotify"
+    NOTIFY = "notify"
 
 
 class NotificationProviderBase(BaseModel):

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

@@ -5741,6 +5741,7 @@ class BambuMQTTClient:
             self.on_print_complete(
                 {
                     "status": status,
+                    "subtask_id": self.state.subtask_id,
                     "filename": self._previous_gcode_file or current_file,
                     "subtask_name": self.state.subtask_name,
                     "raw_data": data,

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

@@ -25,6 +25,7 @@ from backend.app.models.notification import (
     TelegramPendingVerdict,
 )
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials, notify_supports_photos
 from backend.app.services.print_confirmation import one_tap_url
 from backend.app.utils.notification_photos import save_notification_photo
 
@@ -367,6 +368,8 @@ class NotificationService:
             title = "Bambuddy Test"
             message = "This is a test notification. If you see this, notifications are working!"
 
+        if provider_type == "notify" and not notify_supports_photos(config.get("device_id")):
+            attach_photo = False
         image_data = await asyncio.to_thread(_load_sample_notification_image) if attach_photo else None
 
         try:
@@ -398,12 +401,46 @@ class NotificationService:
             elif provider_type == "gotify":
                 photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
                 return await self._send_gotify(config, title, message, event_type="test", image_url=photo_url)
+            elif provider_type == "notify":
+                photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
+                return await self._send_notify(config, title, message, image_url=photo_url)
             else:
                 return False, f"Unknown provider type: {provider_type}"
         except Exception as e:
             logger.exception("Error sending test notification via %s", provider_type)
             return False, str(e)
 
+    async def _send_notify(
+        self,
+        config: dict,
+        title: str,
+        message: str,
+        image_url: str | None = None,
+        event_type: str | None = None,
+        printer_id: int | None = None,
+    ) -> tuple[bool, str]:
+        """Ordinary Notify! alerts use the existing event, digest and quiet-hours rules."""
+        try:
+            device_id, token = notify_credentials(config)
+            client = NotifyClient(await self._get_client())
+            group_type = str(config.get("group_type") or "").strip()
+            if not group_type:
+                group_type = f"bambuddy-printer-{printer_id}" if printer_id is not None else "bambuddy"
+            await client.send_notification(
+                device_id,
+                token,
+                title=title,
+                text=message,
+                group_type=group_type,
+                icon_url=config.get("icon_url"),
+                image_url=image_url,
+                time_sensitive=config.get("time_sensitive") is True
+                and event_type in {"print_failed", "print_stopped", "printer_error", "ai_failure_detection"},
+            )
+            return True, "Message sent successfully"
+        except NotifyError as exc:
+            return False, str(exc)
+
     async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
         """Send notification via CallMeBot (WhatsApp)."""
         phone = config.get("phone", "").strip()
@@ -1322,6 +1359,7 @@ class NotificationService:
         event_type: str | None = None,
         variables: dict | None = None,
         photo_cache: dict | None = None,
+        printer_id: int | None = None,
     ) -> tuple[bool, str]:
         """Send notification to a specific provider.
 
@@ -1469,6 +1507,15 @@ class NotificationService:
                     else None
                 )
                 return await self._send_bark(config, title, message, url=bark_url, image_url=photo_url)
+            elif provider.provider_type == "notify":
+                photo_url = (
+                    await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
+                    if provider.attach_photo and notify_supports_photos(config.get("device_id"))
+                    else None
+                )
+                return await self._send_notify(
+                    config, title, message, image_url=photo_url, event_type=event_type, printer_id=printer_id
+                )
             elif provider.provider_type == "gotify":
                 # Outcome confirmation (#1898): like Bark, Gotify opens one URL
                 # on tap -- deep-link into the confirmation dialog.
@@ -1599,6 +1646,7 @@ class NotificationService:
                     event_type=event_type,
                     variables=variables,
                     photo_cache=photo_cache,
+                    printer_id=printer_id,
                 )
 
                 # Also queue for digest if enabled (digest is a summary, not a queue)

+ 312 - 0
backend/app/services/notify_client.py

@@ -0,0 +1,312 @@
+"""Notify! Partner API client. Resource retries belong to the lifecycle owner.
+
+The gateway requires a query token, so never expose request URLs or response
+bodies in errors. In particular an uncertain start must retain its activity ID
+instead of being retried as a new tile. Contract: getnotifyapp.com/apidocs/.
+"""
+
+import logging
+import re
+from typing import Any
+from urllib.parse import urlsplit
+
+import httpx
+
+from backend.app.core.logging_filters import redact_query_tokens
+
+BASE_URL = "https://push.getnotifyapp.com"
+_ID = re.compile(r"[A-Za-z0-9]{8,32}\Z")
+_ACTIVITY_ID = re.compile(r"LA[A-Z0-9]{6}\Z")
+_WIDGET_ID = re.compile(r"WG[A-Z0-9]{6}\Z")
+
+
+class _NotifyTokenFilter(logging.Filter):
+    def filter(self, record: logging.LogRecord) -> bool:
+        # httpx formats URL *objects*, which the string-only access-log
+        # filter cannot redact. Cover debug sessions as well as normal use.
+        message = record.getMessage()
+        if BASE_URL in message:
+            record.msg = redact_query_tokens(message)
+            record.args = ()
+        return True
+
+
+_http_logger = logging.getLogger("httpx")
+if not any(isinstance(item, _NotifyTokenFilter) for item in _http_logger.filters):
+    _http_logger.addFilter(_NotifyTokenFilter())
+
+
+class NotifyError(Exception):
+    """Safe user-facing error plus the gateway's structured recovery hints."""
+
+    def __init__(
+        self,
+        message: str,
+        *,
+        status_code: int | None = None,
+        payload: dict | None = None,
+        retry_after_seconds: int | None = None,
+        delivery_state: str | None = None,
+    ):
+        super().__init__(message)
+        self.status_code = status_code
+        self.payload = payload or {}
+        activity_id = self.payload.get("activityId")
+        self.activity_id = activity_id if isinstance(activity_id, str) and _ACTIVITY_ID.fullmatch(activity_id) else None
+        widget_id = self.payload.get("widgetId")
+        self.widget_id = widget_id if isinstance(widget_id, str) and _WIDGET_ID.fullmatch(widget_id) else None
+        self.delivery_state = delivery_state or self.payload.get("deliveryState")
+        self.retry_after_seconds = retry_after_seconds
+        self.opening_the_app_may_help = self.payload.get("openingTheAppMayHelp") is True
+
+
+def notify_credentials(config: dict) -> tuple[str, str]:
+    """Accept legacy and newer device IDs, and group IDs, without guessing a platform."""
+    device_id = config.get("device_id")
+    token = config.get("token")
+    if not isinstance(device_id, str) or not _ID.fullmatch(device_id.strip()):
+        raise NotifyError("Notify! requires a valid device or group ID (8–32 letters and digits)")
+    if not isinstance(token, str) or not token.strip():
+        raise NotifyError("Notify! requires a device or group token")
+    for field in (
+        "live_activities",
+        "lock_screen_widgets",
+        "live_activity_privacy",
+        "live_activity_stage",
+        "time_sensitive",
+    ):
+        if field in config and not isinstance(config[field], bool):
+            raise NotifyError(f"Notify! {field} must be a boolean")
+    if config.get("live_activities") is True and device_id.strip().upper().startswith(("GRP", "WB", "MC")):
+        raise NotifyError("Notify! Live Activities require an iOS device ID")
+    if config.get("lock_screen_widgets") is True and device_id.strip().upper().startswith(("GRP", "WB", "MC")):
+        raise NotifyError("Notify! Lock Screen widgets require an iOS device ID")
+    for field in ("icon_url", "live_activity_button_url"):
+        if config.get(field) and not _https_url(config[field]):
+            raise NotifyError(f"Notify! {field} must be an HTTPS URL without embedded credentials")
+    button = config.get("live_activity_button_url")
+    if button and len(button) > 512:
+        raise NotifyError("Notify! dashboard URL must be at most 512 characters")
+    if config.get("live_activity_style") not in (None, "", "bar", "segments", "none"):
+        raise NotifyError("Notify! progress style must be bar, segments, or none")
+    tint = config.get("live_activity_tint")
+    if tint and (not isinstance(tint, str) or not re.fullmatch(r"#[0-9a-fA-F]{6}", tint)):
+        raise NotifyError("Notify! tint must be a color in #RRGGBB format")
+    symbol = config.get("live_activity_symbol")
+    if symbol and (not isinstance(symbol, str) or len(symbol) > 64 or "\x00" in symbol):
+        raise NotifyError("Notify! symbol must be at most 64 characters without NUL")
+    metrics = config.get("live_activity_metrics", [])
+    choices = {"progress", "eta", "layers", "nozzle", "bed", "chamber"}
+    if (
+        not isinstance(metrics, list)
+        or len(metrics) > 6
+        or any(not isinstance(m, str) or m not in choices for m in metrics)
+    ):
+        raise NotifyError("Notify! metrics must contain up to six supported metric names")
+    return device_id.strip(), token.strip()
+
+
+def _https_url(value: Any) -> str | None:
+    if not isinstance(value, str) or not value.strip():
+        return None
+    try:
+        parsed = urlsplit(value.strip())
+        if parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.password:
+            return value.strip()
+    except ValueError:
+        pass
+    return None
+
+
+def notify_supports_photos(device_id: Any) -> bool:
+    """Browser and group targets receive text notifications without camera photos."""
+    return isinstance(device_id, str) and not device_id.strip().upper().startswith(("WB", "GRP"))
+
+
+class NotifyClient:
+    def __init__(self, client: httpx.AsyncClient):
+        self.client = client
+
+    async def _request(
+        self, method: str, path: str, token: str, *, params: dict | None = None, content: dict | None = None
+    ) -> dict:
+        if not isinstance(token, str) or not token.strip():
+            raise NotifyError("Notify! requires a device or group token")
+        try:
+            response = await self.client.request(
+                method,
+                f"{BASE_URL}{path}",
+                params={"token": token.strip(), **(params or {})},
+                json=content,
+                follow_redirects=False,
+            )
+        except httpx.HTTPError:
+            # A timeout after a start may mean Apple accepted it. Do not put
+            # str(exc) into a notification log: it can include the query token.
+            raise NotifyError("Notify! could not be reached", delivery_state="unknown") from None
+        try:
+            payload = response.json()
+        except ValueError:
+            payload = {}
+        if not isinstance(payload, dict):
+            payload = {}
+        if not 200 <= response.status_code < 300 or payload.get("success") is False:
+            retry = payload.get("retryAfterSeconds", response.headers.get("Retry-After"))
+            try:
+                retry = max(1, int(retry)) if retry is not None else None
+            except (ValueError, TypeError, OverflowError):
+                retry = None
+            messages = {
+                400: "Notify! rejected the request; check the device registration and message",
+                401: "Notify! rejected the credentials",
+                403: "Notify! rejected the credentials or resource ID",
+                404: "Notify! device or resource was not found",
+                409: "Notify! could not apply the request to the current resource",
+                410: "Notify! resource is no longer available",
+                429: "Notify! rate limit reached; wait before retrying",
+                502: "Notify! could not confirm delivery",
+                503: "Notify! is temporarily unavailable",
+            }
+            if path.startswith("/live-activity/"):
+                messages.update(
+                    {
+                        400: "Notify! rejected the request; check device support and the five Live Activity limit",
+                        409: "Open Notify! on the iOS device and enable Live Activities before starting a tile",
+                        410: "Notify! Live Activity has ended",
+                    }
+                )
+            elif path.startswith("/widgets/"):
+                messages.update(
+                    {
+                        400: "Notify! rejected the widget request; check its content and the ten widget limit",
+                        403: "Notify! rejected the credentials or widget ID",
+                        409: "Notify! requires a precise widget ID when the device has multiple widgets",
+                    }
+                )
+            raise NotifyError(
+                messages.get(response.status_code, f"Notify! request failed (HTTP {response.status_code})"),
+                status_code=response.status_code,
+                payload=payload,
+                retry_after_seconds=retry,
+            )
+        if not payload:
+            raise NotifyError("Notify! returned an invalid response", delivery_state="unknown")
+        return payload
+
+    @staticmethod
+    def _id(value: str) -> str:
+        if not isinstance(value, str) or not _ID.fullmatch(value.strip()):
+            raise NotifyError("Notify! ID must contain 8–32 letters and digits")
+        return value.strip()
+
+    @staticmethod
+    def _activity_id(value: str) -> str:
+        if not isinstance(value, str) or not _ACTIVITY_ID.fullmatch(value):
+            raise NotifyError("Notify! updates and ends require a precise activity ID")
+        return value
+
+    @staticmethod
+    def _widget_id(value: str) -> str:
+        if not isinstance(value, str) or not _WIDGET_ID.fullmatch(value):
+            raise NotifyError("Notify! widget reads, updates and deletes require a precise widget ID")
+        return value
+
+    async def validate(self, device_id: str, token: str) -> dict:
+        return await self._request("GET", "/link", token, params={"id": self._id(device_id)})
+
+    async def send_notification(
+        self,
+        device_id: str,
+        token: str,
+        *,
+        title: str,
+        text: str,
+        group_type: str | None = None,
+        icon_url: str | None = None,
+        image_url: str | None = None,
+        time_sensitive: bool = False,
+    ) -> dict:
+        content: dict[str, Any] = {"title": title, "text": text}
+        if group_type:
+            content["groupType"] = group_type
+        if time_sensitive:
+            content["timeSensitive"] = True
+        if icon_url:
+            icon = _https_url(icon_url)
+            if not icon:
+                raise NotifyError("Notify! icon URL must use HTTPS")
+            content["iconUrl"] = icon
+        # Photos use Bambuddy's existing public photo URL. A LAN/HTTP-only
+        # installation still gets the text, without a broken remote image.
+        if (image := _https_url(image_url)) and notify_supports_photos(device_id):
+            content["imageUrl"] = image
+        result = await self._request("POST", f"/notify-json/{self._id(device_id)}", token, content=content)
+        if result.get("success") is not True:
+            raise NotifyError("Notify! did not confirm notification delivery")
+        if result.get("failureCount", 0):
+            raise NotifyError("Notify! could not deliver to every device in the group")
+        return result
+
+    async def start_activity(self, device_id: str, token: str, content: dict) -> dict:
+        device_id = self._id(device_id)
+        if _ACTIVITY_ID.fullmatch(device_id):
+            # Legacy device IDs can begin with LA. The gateway resolves IDs by
+            # lookup, so new=1 against an activity ID would update someone
+            # else's existing tile rather than create ours. Prove this is a
+            # registered device without rejecting legitimate legacy IDs.
+            identity = await self.validate(device_id, token)
+            if identity.get("type") != "device":
+                raise NotifyError("Notify! Live Activities require a device ID, not an activity or group ID")
+        result = await self._request("POST", f"/live-activity/{device_id}", token, params={"new": "1"}, content=content)
+        activity_id = result.get("activityId")
+        if not isinstance(activity_id, str) or not _ACTIVITY_ID.fullmatch(activity_id):
+            raise NotifyError("Notify! did not return an activity ID", delivery_state="unknown")
+        return result
+
+    async def update_activity(self, activity_id: str, token: str, content: dict) -> dict:
+        return await self._request("POST", f"/live-activity/{self._activity_id(activity_id)}", token, content=content)
+
+    async def end_activity(self, activity_id: str, token: str, content: dict | None = None) -> dict:
+        return await self._request("DELETE", f"/live-activity/{self._activity_id(activity_id)}", token, content=content)
+
+    async def get_activity(self, activity_id: str, token: str) -> dict:
+        return await self._request("GET", f"/live-activity/{self._id(activity_id)}", token)
+
+    async def create_widget(self, device_id: str, token: str, content: dict) -> dict:
+        try:
+            result = await self._request(
+                "POST", f"/widgets/{self._id(device_id)}", token, params={"new": "1"}, content=content
+            )
+        except NotifyError as error:
+            # A server error may follow a committed create. A 503 explicitly
+            # refuses writes; other uncertain creates must not be retried blind.
+            if error.status_code and error.status_code >= 500 and error.status_code != 503 and not error.delivery_state:
+                error.delivery_state = "unknown"
+            raise
+        widget_id = result.get("widgetId")
+        if not isinstance(widget_id, str) or not _WIDGET_ID.fullmatch(widget_id):
+            raise NotifyError("Notify! did not return a widget ID", delivery_state="unknown")
+        return result
+
+    async def update_widget(self, widget_id: str, token: str, content: dict) -> dict:
+        return await self._request("POST", f"/widgets/{self._widget_id(widget_id)}", token, content=content)
+
+    async def get_widget(self, widget_id: str, token: str) -> dict:
+        return await self._request("GET", f"/widgets/{self._widget_id(widget_id)}", token)
+
+    async def list_widgets(self, device_id: str, token: str) -> dict:
+        result = await self._request("GET", f"/widgets/{self._id(device_id)}", token)
+        widgets = result.get("widgets")
+        if not isinstance(widgets, list) or any(
+            not isinstance(widget, dict)
+            or not isinstance(widget.get("widgetId"), str)
+            or not _WIDGET_ID.fullmatch(widget["widgetId"])
+            for widget in widgets
+        ):
+            # Cleanup uses this list to distinguish invalid credentials from
+            # an already deleted widget. A malformed reply cannot prove absence.
+            raise NotifyError("Notify! returned an invalid widget list", delivery_state="unknown")
+        return result
+
+    async def delete_widget(self, widget_id: str, token: str) -> dict:
+        return await self._request("DELETE", f"/widgets/{self._widget_id(widget_id)}", token)

+ 752 - 0
backend/app/services/notify_live_activities.py

@@ -0,0 +1,752 @@
+"""Persistent Notify! Live Activities, independent of push and digest selection.
+
+Only the worker performs network I/O. MQTT callbacks copy their mutable state and
+wake it; database sessions are closed before every HTTP call. A durable intent is
+written *before* starting a tile: after a crash or an ambiguous network failure we
+never blindly repeat a push-to-start and consume another of the five device slots.
+"""
+
+import asyncio
+import hashlib
+import json
+import logging
+from dataclasses import dataclass, replace
+from datetime import datetime, timedelta, timezone
+from uuid import uuid4
+
+import httpx
+from sqlalchemy import delete, select, update
+
+from backend.app.core.database import async_session
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_live_activity import NotificationLiveActivity
+from backend.app.models.printer import Printer
+from backend.app.services.notify_client import NotifyClient, NotifyError, _https_url
+from backend.app.services.notify_worker import NotifyWorkerLifecycle
+
+logger = logging.getLogger(__name__)
+_RUNNING = {"RUNNING", "PRINTING", "PAUSE", "PREPARE", "SLICING"}
+_TERMINAL = {"IDLE", "FINISH", "FAILED", "COMPLETED", "CANCELLED", "ABORTED", "STOPPED"}
+_STOPPED = {"ended", "suppressed"}
+_ROLLOVER = {"expired", "overdue", "abandoned"}
+_INTERVAL = 60
+_STARTUP_GRACE = timedelta(minutes=2)
+
+
+def _now() -> datetime:
+    return datetime.now(timezone.utc).replace(tzinfo=None)
+
+
+def _date(value) -> datetime | None:
+    if not value:
+        return None
+    try:
+        return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc).replace(tzinfo=None)
+    except (ValueError, AttributeError):
+        return None
+
+
+def _config(provider) -> dict:
+    return json.loads(provider.config) if isinstance(provider.config, str) else provider.config or {}
+
+
+def _credential_key(config: dict) -> str:
+    return hashlib.sha256(f"{config.get('device_id', '')}:{config.get('token', '')}".encode()).hexdigest()
+
+
+def _identity(state) -> str:
+    job_id = str(getattr(state, "subtask_id", "") or "")
+    if job_id and job_id != "0":
+        return f"job:{job_id}"[:160]
+    # raw_data contains MQTT deltas, so gcode_start_time may disappear on the
+    # next push. The stable job ID is preferred; the filename fallback is given
+    # a fresh generation by real start callbacks and adopted on restart.
+    filename = getattr(state, "subtask_name", None) or getattr(state, "current_print", None) or ""
+    digest = hashlib.sha256(str(filename).encode()).hexdigest()[:32]
+    return f"file:{digest}"
+
+
+def _printer_fault(state) -> str | None:
+    """Match the printer card's actionable HMS filtering, including print_error.
+
+    Bambu's MQTT parser folds print_error into hms_errors. Level-3 sixteen-digit
+    HMS notices without actions (for example an open cover) are informational;
+    eight-digit task-stopping print_error prompts at that level still count.
+    """
+    for error in getattr(state, "hms_errors", None) or []:
+        severity = getattr(error, "severity", 0)
+        description = str(getattr(error, "description", "") or "")
+        actions = bool(getattr(error, "actions", None))
+        notice = severity == 3 and len(str(getattr(error, "full_code", "") or "")) == 16
+        if severity >= 1 and (actions or (description and not notice)):
+            code = getattr(error, "full_code", "") or getattr(error, "code", "")
+            return (description or f"Printer needs attention ({code})").replace("\x00", "")[:170]
+    return None
+
+
+@dataclass(frozen=True)
+class PrintSnapshot:
+    key: str
+    connected: bool
+    state: str
+    filename: str
+    progress: float
+    remaining_seconds: int
+    layer: int
+    layers: int
+    job_key: str | None = None
+    fault: str | None = None
+    temperatures: tuple = ()
+    stage: int = -1
+
+    @classmethod
+    def from_state(cls, state):
+        return cls(
+            key=_identity(state),
+            job_key=_identity(state) if _identity(state).startswith("job:") else None,
+            connected=bool(state.connected),
+            state=str(state.state or "").upper(),
+            filename=str(getattr(state, "subtask_name", None) or getattr(state, "current_print", None) or "Print"),
+            progress=max(0, min(100, float(state.progress or 0))),
+            remaining_seconds=max(0, int(getattr(state, "remaining_time", 0) or 0) * 60),
+            layer=int(state.layer_num or 0),
+            layers=int(state.total_layers or 0),
+            temperatures=tuple((getattr(state, "temperatures", {}) or {}).items()),
+            stage=getattr(state, "stg_cur", -1),
+            fault=_printer_fault(state),
+        )
+
+    def content(self, printer_name: str, config: dict | None = None) -> dict:
+        config = config or {}
+        phase = "Printing"
+        if not self.connected:
+            phase = "Printer offline"
+        elif self.state == "PAUSE":
+            phase = "Paused"
+        elif self.state in ("PREPARE", "SLICING") or (
+            self.state in {"RUNNING", "PRINTING"}
+            and self.layer < 1
+            and (self.layers > 0 or self.stage not in {0, -1, 255})
+        ):
+            phase = "Preparing"
+        elif self.state in _TERMINAL:
+            phase = {
+                "FINISH": "Complete",
+                "COMPLETED": "Complete",
+                "FAILED": "Failed",
+                "CANCELLED": "Stopped",
+                "ABORTED": "Stopped",
+                "STOPPED": "Stopped",
+                "IDLE": "Finished",
+            }[self.state]
+        if self.connected and self.fault and self.state in _RUNNING:
+            lower = self.fault.lower()
+            runout = "filament" in lower and any(
+                word in lower for word in ("run out", "ran out", "runout", "exhaust", "empty")
+            )
+            phase = "Filament runout" if runout else "Printer error"
+        # Character and UTF-8 limits leave ample room for server-owned fields
+        # within Notify's 2048-byte merged-content budget, including emoji names.
+        filename = (
+            "Print in progress"
+            if config.get("live_activity_privacy") is True
+            else self.filename.replace("\x00", "")[:120]
+        )
+        layer = f" · Layer {self.layer}/{self.layers}" if self.layers else ""
+        progress = 0 if phase == "Preparing" else self.progress
+        content = {
+            "title": printer_name.replace("\x00", "")[:80] or "Bambuddy",
+            "body": self.fault
+            if self.connected and self.fault and self.state in _RUNNING
+            else f"{filename}{layer}"[:170],
+            "symbol": str(config.get("live_activity_symbol") or "printer.fill")[:64],
+            "tint": config.get("live_activity_tint") or "#00AE42",
+            "progress": 100 if phase == "Complete" else progress,
+            "status": phase,
+            "endsIn": self.remaining_seconds
+            if self.connected
+            and not self.fault
+            and self.state in {"RUNNING", "PRINTING"}
+            and 0 < self.remaining_seconds <= 86400
+            else None,
+        }
+        style = config.get("live_activity_style", "bar")
+        content.update(
+            steps=10 if style == "segments" else None, step=int(progress // 10) if style == "segments" else None
+        )
+        if style == "none":
+            content["progress"] = None
+        if (
+            config.get("live_activity_stage") is True
+            and self.connected
+            and not self.fault
+            and self.state in {"RUNNING", "PRINTING"}
+        ):
+            from backend.app.services.bambu_mqtt import get_stage_name
+
+            content["status"] = get_stage_name(self.stage)[:40] if self.stage >= 0 else phase
+        eta = f"{self.remaining_seconds // 3600}h {(self.remaining_seconds // 60) % 60}m"
+        content["trailing"] = eta if self.remaining_seconds > 86400 and phase == "Printing" else None
+        # The default tile uses the native countdown. Metric chips are opt-in;
+        # send null when disabled so merge-patch clears previously selected chips.
+        metrics = []
+        temperatures = dict(self.temperatures)
+        for field in (config.get("live_activity_metrics") or [])[:6]:
+            label, value, unit = "", None, ""
+            if field == "progress":
+                label, value = "Progress", f"{progress:.0f}%"
+            elif field == "eta" and phase == "Printing" and self.remaining_seconds > 0:
+                label, value = "Remaining", eta
+            elif field == "layers" and self.layers:
+                label, value = "Layer", f"{self.layer}/{self.layers}"
+            elif field in {"nozzle", "bed", "chamber"} and temperatures.get(field) is not None:
+                label, value, unit = field.title(), f"{temperatures[field]:.0f}", "°C"
+            if value is not None:
+                metrics.append({"label": label, "value": value[:16], "unit": unit})
+        content["metrics"] = None if self.fault else metrics or None
+        button = _https_url(config.get("live_activity_button_url"))
+        content["button"] = (
+            {"title": "Open Bambuddy", "url": button, "open": True} if button and len(button) <= 512 else None
+        )
+
+        # Notify adds timestamps when merging. Reserve 200 bytes for those;
+        # shorten display text first, then drop optional cells/button if needed.
+        def size():
+            return len(json.dumps(content, ensure_ascii=False, separators=(",", ":")).encode())
+
+        while size() > 1800 and len(content["body"]) > 20:
+            content["body"] = content["body"][:-10]
+        if size() > 1800:
+            content["button"] = None
+        while size() > 1800 and content["metrics"]:
+            content["metrics"].pop()
+        return content
+
+
+class NotifyLiveActivityService(NotifyWorkerLifecycle):
+    ownership_model = NotificationLiveActivity
+    feature_field = "live_activities"
+    worker_interval = 30
+    worker_name = "notify-live-activities"
+    stopped_states = ("ended", "suppressed")
+    _cleanup_key = staticmethod(_credential_key)
+
+    def __init__(self, session_factory=None, client=None):
+        self._session = session_factory or async_session
+        self._client = client
+        self._http: httpx.AsyncClient | None = None
+        self._snapshots: dict[int, PrintSnapshot] = {}
+        self._new_fallbacks: dict[int, str] = {}
+        self._started_at: dict[int, datetime] = {}
+        self._progress_resets: dict[int, tuple[float, datetime]] = {}
+        self._finished: list[tuple[int, str | None, str]] = []
+        self._lock = asyncio.Lock()
+        self._wake = asyncio.Event()
+        self._task: asyncio.Task | None = None
+        self._init_worker()
+        self._status_grace_until: datetime | None = None
+
+    def _on_activated(self):
+        # No MQTT observations are accepted while dormant. Anything retained
+        # from before opt-out is stale until a real status arrives after opt-in.
+        self._snapshots.clear()
+        self._finished.clear()
+        self._new_fallbacks.clear()
+        self._started_at.clear()
+        self._progress_resets.clear()
+        self._status_grace_until = None
+
+    def observe(self, printer_id: int, state) -> None:
+        if self._provider_enabled is False:
+            return
+        snapshot = PrintSnapshot.from_state(state)
+        if snapshot.state in {"", "UNKNOWN"}:
+            previous = self._snapshots.get(printer_id)
+            if previous is None or snapshot.connected:
+                return  # Broker connection alone is not a real printer status.
+            snapshot = replace(previous, connected=False)
+        reset = self._progress_resets.get(printer_id)
+        if reset:
+            initial, until = reset
+            if (
+                snapshot.state not in _RUNNING
+                or snapshot.progress < initial
+                or snapshot.progress <= 5
+                or _now() >= until
+            ):
+                self._progress_resets.pop(printer_id, None)
+            else:
+                snapshot = replace(snapshot, progress=0, remaining_seconds=0)
+        previous = self._snapshots.get(printer_id)
+        if (
+            previous
+            and previous.state in _RUNNING
+            and snapshot.state in _RUNNING
+            and previous.filename == snapshot.filename
+        ):
+            # A delta may omit or belatedly supply a job ID. Retain the print's
+            # established identity until an explicit start or a different ID.
+            if snapshot.key.startswith("file:") or previous.key.startswith("file:"):
+                snapshot = replace(snapshot, key=previous.key)
+        self._snapshots[printer_id] = snapshot
+        if self._provider_enabled and (
+            previous is None
+            or (previous.key, previous.state, previous.connected, previous.fault)
+            != (
+                snapshot.key,
+                snapshot.state,
+                snapshot.connected,
+                snapshot.fault,
+            )
+        ):
+            self._wake.set()
+
+    def print_started(self, printer_id: int, state, data: dict | None = None) -> None:
+        if state is None or self._provider_enabled is False:
+            return
+        snapshot = PrintSnapshot.from_state(state)
+        job_id = (data or {}).get("subtask_id") or ((data or {}).get("raw_data") or {}).get("subtask_id")
+        if job_id and str(job_id) != "0":
+            snapshot = replace(snapshot, key=f"job:{job_id}", job_key=f"job:{job_id}")
+        self._progress_resets[printer_id] = (snapshot.progress, _now() + timedelta(minutes=2))
+        snapshot = replace(snapshot, progress=0, remaining_seconds=0)
+        self._started_at[printer_id] = _now()
+        self._new_fallbacks.pop(printer_id, None)
+        if snapshot.key.startswith("file:"):
+            self._new_fallbacks[printer_id] = f"{snapshot.key}:{uuid4().hex}"
+        self._snapshots[printer_id] = snapshot
+        self._wake.set()
+
+    def print_finished(self, printer_id: int, data: dict) -> None:
+        if self._provider_enabled is False:
+            return
+        job = data.get("subtask_id") or (data.get("raw_data") or {}).get("subtask_id")
+        snapshot = self._snapshots.get(printer_id)
+        filename = data.get("subtask_name") or data.get("filename")
+        key = f"job:{job}" if job and str(job) != "0" else None
+        if key is None and snapshot and (not filename or snapshot.filename == filename):
+            key = (
+                self._new_fallbacks.get(printer_id, snapshot.key) if snapshot.key.startswith("file:") else snapshot.key
+            )
+        if key is None:
+            # No unambiguous ownership: the real terminal state can reconcile
+            # it, but a delayed event must never end the printer's next job.
+            return
+        self._finished.append((printer_id, key, str(data.get("status", "completed")).upper()))
+        snapshot = self._snapshots.get(printer_id)
+        if snapshot and key in (snapshot.key, snapshot.job_key):
+            self._snapshots[printer_id] = replace(snapshot, state=self._finished[-1][2], remaining_seconds=0)
+        self._wake.set()
+
+    async def _api(self):
+        if self._client is None:
+            self._http = httpx.AsyncClient(
+                timeout=httpx.Timeout(30, connect=5), follow_redirects=False, headers={"User-Agent": "Bambuddy/1.0"}
+            )
+            self._client = NotifyClient(self._http)
+        return self._client
+
+    async def _save(self, row) -> None:
+        async with self._session() as db:
+            values = {column.name: getattr(row, column.name) for column in row.__table__.columns if column.name != "id"}
+            # Never resurrect ownership after provider deletion. A queued cleanup
+            # still holds this object's newly returned remote ID.
+            await db.execute(
+                update(NotificationLiveActivity).where(NotificationLiveActivity.id == row.id).values(**values)
+            )
+            await db.commit()
+
+    async def tick(self) -> None:
+        async with self._lock:
+            async with self._session() as db:
+                providers = (
+                    await db.scalars(select(NotificationProvider).where(NotificationProvider.provider_type == "notify"))
+                ).all()
+                any_enabled = any(self._eligible(provider) for provider in providers)
+                previously_enabled = self._provider_enabled
+                self._provider_enabled = any_enabled
+                if any_enabled and previously_enabled is False:
+                    self._on_activated()
+                if any_enabled and self._status_grace_until is None:
+                    # Start when reconciliation begins, not at module import.
+                    self._status_grace_until = _now() + _STARTUP_GRACE
+                if not any_enabled and not previously_enabled and not self._cleanup_once and not self._cleanup_pending:
+                    return
+                rows = (await db.scalars(select(NotificationLiveActivity))).all()
+                self._working_rows = rows
+                names = dict((await db.execute(select(Printer.id, Printer.name))).all()) if any_enabled else {}
+            finished, self._finished = self._finished, []
+            snapshots = self._snapshots.copy()
+            for provider in providers:
+                config = _config(provider)
+                owned = [r for r in rows if r.provider_id == provider.id]
+                enabled = bool(
+                    provider.enabled
+                    and config.get("live_activities") is True
+                    and not str(config.get("device_id", "")).upper().startswith(("GRP", "MC", "WB"))
+                )
+                for row in owned:
+                    snapshot = snapshots.get(row.printer_id)
+                    selected = provider.printer_id is None or provider.printer_id == row.printer_id
+                    if (
+                        row.state == "ended"
+                        and row.end_reason == "disabled"
+                        and enabled
+                        and selected
+                        and snapshot
+                        and snapshot.connected
+                        and snapshot.state in _RUNNING
+                        and self._matches(row, snapshot, row.printer_id)
+                    ):
+                        # An explicit re-enable may resume this job's tile. A
+                        # user-dismissed/failed-to-appear tile remains suppressed.
+                        row.state, row.activity_id, row.end_reason = "pending", None, None
+                        row.failures, row.next_attempt_at, row.last_sent_at = 0, None, None
+                        await self._save(row)
+                    if (
+                        snapshot
+                        and snapshot.job_key
+                        and not row.job_key
+                        and self._matches(row, snapshot, row.printer_id)
+                    ):
+                        row.job_key = snapshot.job_key
+                        await self._save(row)
+                    if self._retired_row(row):
+                        continue
+                    if row.state in _STOPPED:
+                        continue
+                    if row.credential_key != _credential_key(config):
+                        # The edit hook owns cleanup using the old credentials.
+                        continue
+                    if row.state == "ending":
+                        await self._end(row, config, row.end_reason or "STOPPED")
+                        continue
+                    snapshot = snapshots.get(row.printer_id)
+                    final = next(
+                        (
+                            status
+                            for pid, key, status in finished
+                            if pid == row.printer_id and key in (row.print_key, row.job_key)
+                        ),
+                        None,
+                    )
+                    selected = provider.printer_id is None or provider.printer_id == row.printer_id
+                    same_print = snapshot and self._matches(row, snapshot, row.printer_id)
+                    if final or not enabled or not selected or row.printer_id not in names:
+                        await self._end(
+                            row, config, final or ("DISABLED" if not enabled or not selected else "STOPPED")
+                        )
+                    elif snapshot and snapshot.connected and snapshot.state in _TERMINAL:
+                        await self._end(row, config, snapshot.state)
+                    elif snapshot and snapshot.connected and snapshot.state in _RUNNING and not same_print:
+                        await self._end(row, config, "STOPPED")
+                    elif snapshot is None and self._status_grace_until and _now() < self._status_grace_until:
+                        # Allow reconnect time without leaving a switched-off
+                        # printer's saved countdown running indefinitely.
+                        continue
+                    else:
+                        # A missing/offline printer is not evidence the print ended.
+                        # Clear its countdown and keep its last known progress.
+                        content = (
+                            snapshot.content(names[row.printer_id], config) if snapshot else json.loads(row.content)
+                        )
+                        if snapshot is None or not snapshot.connected:
+                            content.update(status="Printer offline", endsIn=None, trailing=None)
+                        if snapshot is None:
+                            content["metrics"] = None  # Saved ETA/temperature chips are stale after a restart.
+                        await self._sync(
+                            row,
+                            config,
+                            content,
+                            can_start=bool(
+                                snapshot
+                                and snapshot.connected
+                                and snapshot.state in _RUNNING
+                                and enabled
+                                and not self._quiet(provider)
+                            ),
+                        )
+                if not enabled or self._quiet(provider):
+                    continue
+                for printer_id, snapshot in snapshots.items():
+                    if (
+                        not snapshot.connected
+                        or snapshot.state not in _RUNNING
+                        or printer_id not in names
+                        or provider.printer_id not in (None, printer_id)
+                    ):
+                        continue
+                    # Finish the old tile before using another device slot for
+                    # this printer. A failed DELETE remains durable and retries.
+                    if any(r.printer_id == printer_id and r.state == "ending" and r.activity_id for r in owned):
+                        continue
+                    if any(self._matches(r, snapshot, printer_id) for r in owned):
+                        continue
+                    key = (
+                        self._new_fallbacks.get(printer_id, snapshot.key)
+                        if snapshot.key.startswith("file:")
+                        else snapshot.key
+                    )
+                    row = NotificationLiveActivity(
+                        provider_id=provider.id,
+                        printer_id=printer_id,
+                        print_key=key,
+                        print_name=snapshot.filename[:255],
+                        job_key=snapshot.job_key,
+                        credential_key=_credential_key(config),
+                        state="pending",
+                        content=json.dumps(snapshot.content(names[printer_id], config)),
+                        created_at=_now(),
+                    )
+                    if self._retired_row(row):
+                        continue
+                    self._working_rows.append(row)
+                    async with self._session() as db:
+                        db.add(row)
+                        await db.commit()
+                    await self._sync(row, config, json.loads(row.content), can_start=True)
+            self._cleanup_pending = any(
+                row.state == "ending" and row.provider_id in {p.id for p in providers} for row in rows
+            )
+            # Tombstones need no maintenance while the integration is dormant.
+            if not any_enabled:
+                return
+            # Tombstones must outlive any plausible print, but need not grow forever.
+            async with self._session() as db:
+                await db.execute(
+                    delete(NotificationLiveActivity).where(
+                        NotificationLiveActivity.state.in_(_STOPPED),
+                        NotificationLiveActivity.created_at < _now() - timedelta(days=30),
+                        NotificationLiveActivity.printer_id.not_in(
+                            [pid for pid, snapshot in snapshots.items() if snapshot.state in _RUNNING]
+                        ),
+                    )
+                )
+                await db.commit()
+
+    def _matches(self, row, snapshot, printer_id):
+        if row.printer_id != printer_id:
+            return False
+        if row.job_key and snapshot.job_key:
+            return row.job_key == snapshot.job_key
+        fallback = self._new_fallbacks.get(printer_id)
+        if fallback and snapshot.key.startswith("file:"):
+            return row.print_key == fallback
+        if (
+            row.print_key.startswith("file:")
+            and snapshot.key.startswith("job:")
+            and row.print_name == snapshot.filename[:255]
+            and row.job_key is None
+            and self._started_at.get(printer_id, row.created_at) <= row.created_at
+        ):
+            return True  # Restart after firmware finally supplied its job ID.
+        return row.print_key == snapshot.key or (
+            snapshot.key.startswith("file:") and row.print_key.startswith(snapshot.key + ":")
+        )
+
+    @staticmethod
+    def _quiet(provider) -> bool:
+        # Share Bambuddy's configured/local-time quiet-hours semantics. Digest
+        # and event toggles deliberately never participate in this lifecycle.
+        from backend.app.services.notification_service import notification_service
+
+        return notification_service._is_in_quiet_hours(provider)
+
+    async def _sync(self, row, config, content, *, can_start):
+        if self._retired_row(row):
+            return
+        now = _now()
+        urgent = json.loads(row.content).get("status") != content.get("status")
+        if row.next_attempt_at and row.next_attempt_at > now and not (urgent and row.last_sent_at and not row.failures):
+            return
+        # Store an absolute deadline rather than repeatedly resetting a frozen
+        # MQTT minute estimate. Refresh only when that estimate or phase changes.
+        seconds = content.get("endsIn")
+        if seconds is None:
+            row.eta_seconds, row.eta_deadline = None, None
+        elif seconds != row.eta_seconds or row.eta_deadline is None:
+            row.eta_seconds = seconds
+            row.eta_deadline = now + timedelta(seconds=seconds)
+        if row.eta_deadline:
+            remaining = int((row.eta_deadline - now).total_seconds())
+            content["endsIn"] = remaining if remaining > 0 else None
+        api = await self._api()
+        token = config.get("token", "")
+        try:
+            if row.activity_id:
+                remote = await api.get_activity(row.activity_id, token)
+                state, reason = remote.get("state"), remote.get("endReason")
+                if state in {"ended", "dismissed"}:
+                    if reason not in _ROLLOVER or not can_start:
+                        row.state = "suppressed" if reason not in _ROLLOVER else "pending"
+                        row.end_reason = reason or state
+                        if row.state == "pending":
+                            row.activity_id = None
+                        await self._save(row)
+                        return
+                    row.activity_id = None
+                    row.state = "pending"
+                    await self._save(row)
+                else:
+                    row.state = state or "active"
+                    row.expires_at = _date(remote.get("expiresAt")) or row.expires_at
+                    if can_start and row.expires_at and row.expires_at <= now:
+                        # Poll first: a dismissed tile must never be rolled over.
+                        await api.end_activity(row.activity_id, token)
+                        row.activity_id = None
+                        row.state = "pending"
+                        await self._save(row)
+                    else:
+                        if self._retired_row(row):
+                            return
+                        await api.update_activity(row.activity_id, token, content)
+                        row.content = json.dumps(content)
+                        row.failures = 0
+                        row.last_sent_at = now
+                        row.next_attempt_at = now + timedelta(seconds=_INTERVAL)
+                        await self._save(row)
+                        return
+            if row.state == "uncertain":
+                # No usable ID after a timeout/crash. The API has no client
+                # idempotency key; do not adopt an unrelated device tile.
+                row.state = "suppressed"
+                row.end_reason = "unconfirmed-start"
+                await self._save(row)
+                return
+            if not can_start or row.state in _STOPPED:
+                return
+            row.state = "uncertain"
+            row.content = json.dumps(content)
+            await self._save(row)
+            if self._retired_row(row):
+                return
+            result = await api.start_activity(config.get("device_id", ""), token, content)
+            row.activity_id = result["activityId"]
+            row.state = "starting"
+            row.failures = 0
+            row.expires_at = _date(result.get("expiresAt")) or now + timedelta(hours=8)
+            row.last_sent_at = now
+            row.next_attempt_at = now + timedelta(seconds=_INTERVAL)
+            await self._save(row)
+        except NotifyError as error:
+            await self._failure(row, error)
+
+    async def _failure(self, row, error):
+        ending = row.state == "ending"
+        if error.activity_id:
+            row.activity_id = error.activity_id
+            row.state = "starting"
+        elif not row.activity_id and error.delivery_state == "unknown":
+            row.state = "suppressed"
+            row.end_reason = "unconfirmed-start"
+        elif error.status_code in (401, 403):
+            row.state = "suppressed"
+            row.end_reason = "credentials"
+        elif error.status_code == 410 and row.activity_id:
+            # GET on the next pass determines whether this was a dismissal or
+            # the eight-hour ceiling; never infer rollover from HTTP 410 alone.
+            pass
+        elif not row.activity_id:
+            if error.status_code in (409, 429, 503) or error.retry_after_seconds is not None:
+                row.state = "pending"
+            elif error.status_code == 400 and "5" in str(error.payload.get("message", "")):
+                row.state = "pending"  # Device cap: another printer may free a slot.
+            else:
+                row.state = "suppressed"
+                row.end_reason = "start-rejected"
+        if ending:
+            row.state = "ending"
+        row.failures = min((row.failures or 0) + 1, 5)
+        row.next_attempt_at = _now() + timedelta(
+            seconds=max(60 * 2 ** (row.failures - 1), error.retry_after_seconds or 0)
+        )
+        # Do not log upstream bodies, URLs, or credentials.
+        logger.warning(
+            "Notify Live Activity request failed for provider %s (HTTP %s)", row.provider_id, error.status_code
+        )
+        await self._save(row)
+        message = str(error)
+        if error.retry_after_seconds:
+            message += f" Retry after {error.retry_after_seconds} seconds."
+        async with self._session() as db:
+            await db.execute(
+                update(NotificationProvider)
+                .where(NotificationProvider.id == row.provider_id)
+                .values(
+                    last_error=message,
+                    last_error_at=_now(),
+                )
+            )
+            await db.commit()
+
+    async def _end(self, row, config, status):
+        content = json.loads(row.content)
+        content.update(
+            status={"COMPLETED": "Complete", "FINISH": "Complete", "FAILED": "Failed"}.get(status, "Stopped"),
+            endsIn=None,
+        )
+        if status in {"COMPLETED", "FINISH"}:
+            content["progress"] = 100
+        # Persist the terminal decision before I/O. A failed end must still
+        # finish this exact job after restart or after the next job begins.
+        if row.state != "ending":
+            row.state = "ending"
+            row.end_reason = status
+            row.content = json.dumps(content)
+            await self._save(row)
+        if row.failures and row.next_attempt_at and row.next_attempt_at > _now():
+            return
+        if row.activity_id:
+            try:
+                await (await self._api()).end_activity(row.activity_id, config.get("token", ""), content)
+            except NotifyError as error:
+                if error.status_code not in (403, 404, 410):
+                    await self._failure(row, error)
+                    return
+        row.state = "ended"
+        row.end_reason = status.lower()
+        row.content = json.dumps(content)
+        await self._save(row)
+
+    async def cleanup_provider(self, provider_id: int, old_config: dict, *, captured_rows=None) -> None:
+        """Called after saving a credential/scope change or deleting a provider.
+
+        Cleanup is best effort (the remote eight-hour ceiling bounds an outage).
+        No old credential is retained in a second database location.
+        """
+        async with self._lock:
+            async with self._session() as db:
+                rows = (
+                    await db.scalars(
+                        select(NotificationLiveActivity).where(
+                            NotificationLiveActivity.provider_id == provider_id,
+                            NotificationLiveActivity.credential_key == _credential_key(old_config),
+                        )
+                    )
+                ).all()
+            if captured_rows is not None:
+                merged = {row.id: row for row in rows}
+                merged.update(
+                    {row.id: row for row in captured_rows if row.credential_key == _credential_key(old_config)}
+                )
+                rows = list(merged.values())
+            for row in rows:
+                if row.activity_id and row.state not in _STOPPED:
+                    try:
+                        await (await self._api()).end_activity(row.activity_id, old_config.get("token", ""))
+                    except NotifyError:
+                        logger.warning("Notify Live Activity cleanup failed for provider %s", provider_id)
+            async with self._session() as db:
+                await db.execute(
+                    delete(NotificationLiveActivity).where(
+                        NotificationLiveActivity.provider_id == provider_id,
+                        NotificationLiveActivity.credential_key == _credential_key(old_config),
+                    )
+                )
+                await db.commit()
+            for row in rows:
+                row.state = "ended"
+        self._wake.set()
+
+
+notify_live_activities = NotifyLiveActivityService()

+ 456 - 0
backend/app/services/notify_widgets.py

@@ -0,0 +1,456 @@
+"""Persistent Notify! Lock Screen widgets, separate from Live Activities.
+
+These widgets hold stored values: iOS chooses when to refresh them (usually about
+15 minutes). Bambuddy updates changed content at most once per minute, never
+promises a ticking countdown, and addresses only its own saved WG identifiers.
+"""
+
+import asyncio
+import json
+import logging
+from datetime import timedelta
+
+import httpx
+from sqlalchemy import and_, case, delete, or_, select, update
+
+from backend.app.core.database import async_session
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
+from backend.app.models.printer import Printer
+from backend.app.services.notify_client import NotifyClient, NotifyError
+from backend.app.services.notify_live_activities import PrintSnapshot, _credential_key as _fingerprint, _now
+from backend.app.services.notify_worker import NotifyWorkerLifecycle
+
+logger = logging.getLogger(__name__)
+_ERROR_PREFIX = "Notify! Lock Screen widget: "
+_CAPACITY_ERROR_PREFIX = "Notify! widget capacity: "
+_INTERVAL = 60
+
+
+def _credential_key(config):
+    return _fingerprint(
+        {
+            "device_id": str(config.get("device_id", "")).strip(),
+            "token": str(config.get("token", "")).strip(),
+        }
+    )
+
+
+def widget_content(printer_name: str, state) -> dict:
+    """A static, compact status card. Faults replace the gauge with a diagnosis."""
+    content = {
+        "title": printer_name.replace("\x00", "")[:120] or "Bambuddy",
+        "value": "Offline",
+        "unit": None,
+        "detail": "Printer is not connected",
+        "symbol": "printer.fill",
+        "tint": "#00AE42",
+        "progress": None,
+    }
+    if state is not None and state.connected:
+        # Shared parsing keeps HMS advisory filtering and preparation-stage
+        # progress consistent with Bambuddy's Live Activity and printer card.
+        snapshot = PrintSnapshot.from_state(state)
+        tile = snapshot.content(printer_name)
+        phase = tile["status"]
+        if snapshot.fault:
+            content.update(value="Error", detail=snapshot.fault[:120], tint="#E5484D")
+        elif phase == "Paused":
+            content.update(value="Paused", detail=f"Print paused at {snapshot.progress:.0f}%")
+        elif snapshot.state in {"RUNNING", "PRINTING", "PREPARE", "SLICING"}:
+            progress = tile["progress"]
+            parts = [phase]
+            if snapshot.layers:
+                parts.append(f"Layer {snapshot.layer}/{snapshot.layers}")
+            if snapshot.remaining_seconds > 0:
+                minutes = snapshot.remaining_seconds // 60
+                eta = f"{minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"{minutes}m"
+                parts.append(f"About {eta} left")
+            content.update(value=f"{progress:.0f}", unit="%", detail=" · ".join(parts)[:120], progress=progress)
+        elif snapshot.state in {"FINISH", "COMPLETED"}:
+            content.update(value="Complete", detail="Print completed", progress=100)
+        elif snapshot.state == "FAILED":
+            content.update(value="Failed", detail="Print failed", tint="#E5484D")
+        elif snapshot.state in {"CANCELLED", "ABORTED", "STOPPED"}:
+            content.update(value="Stopped", detail="Print stopped")
+        elif snapshot.state == "IDLE":
+            content.update(value="Idle", detail="Ready to print")
+        else:
+            content.update(value="Connecting", detail="Waiting for printer status")
+    # Notify caps the serialized merged content at 1024 UTF-8 bytes. Reserve
+    # space rather than letting emoji printer names make every update fail.
+    while len(json.dumps(content, ensure_ascii=False, separators=(",", ":")).encode()) > 950:
+        field = "detail" if len(content["detail"]) > 20 else "title"
+        content[field] = content[field][:-5]
+    return content
+
+
+def _status(printer_id):
+    from backend.app.services.printer_manager import printer_manager
+
+    return printer_manager.get_status(printer_id)
+
+
+class NotifyWidgetService(NotifyWorkerLifecycle):
+    ownership_model = NotificationLockScreenWidget
+    feature_field = "lock_screen_widgets"
+    worker_name = "notify-lock-screen-widgets"
+    _cleanup_key = staticmethod(_credential_key)
+
+    def __init__(self, session_factory=None, client=None, state_getter=None):
+        self._session = session_factory or async_session
+        self._client = client
+        self._http: httpx.AsyncClient | None = None
+        self._state = state_getter or _status
+        self._task: asyncio.Task | None = None
+        self._lock = asyncio.Lock()
+        self._init_worker()
+        self._capacity_devices: set[str] = set()
+        self._retry_capacity = False
+        self._over_capacity_providers: set[int] = set()
+
+    def providers_changed(self):
+        self._retry_capacity = True
+        self._capacity_devices.clear()
+        super().providers_changed()
+
+    async def _api(self):
+        if self._client is None:
+            self._http = httpx.AsyncClient(
+                timeout=httpx.Timeout(30, connect=5), follow_redirects=False, headers={"User-Agent": "Bambuddy/1.0"}
+            )
+            self._client = NotifyClient(self._http)
+        return self._client
+
+    async def _save(self, row):
+        async with self._session() as db:
+            values = {column.name: getattr(row, column.name) for column in row.__table__.columns if column.name != "id"}
+            # A provider edit may request deletion while this worker is awaiting
+            # HTTP. Preserve that intent while capturing a just-returned WG ID,
+            # otherwise a rapid off/on would silently cancel the user's cleanup.
+            values["state"] = case((NotificationLockScreenWidget.state == "deleting", "deleting"), else_=row.state)
+            await db.execute(
+                update(NotificationLockScreenWidget).where(NotificationLockScreenWidget.id == row.id).values(**values)
+            )
+            await db.commit()
+
+    async def _remove(self, row):
+        async with self._session() as db:
+            await db.execute(delete(NotificationLockScreenWidget).where(NotificationLockScreenWidget.id == row.id))
+            await db.commit()
+        row.state = "deleted"
+
+    async def tick(self):
+        async with self._lock:
+            async with self._session() as db:
+                providers = (
+                    await db.scalars(select(NotificationProvider).where(NotificationProvider.provider_type == "notify"))
+                ).all()
+                enabled = any(self._eligible(provider) for provider in providers)
+                previously_enabled = self._provider_enabled
+                self._provider_enabled = enabled
+                if not enabled and not previously_enabled and not self._cleanup_once and not self._cleanup_pending:
+                    return
+                rows = (await db.scalars(select(NotificationLockScreenWidget))).all()
+                self._working_rows = rows
+                printers = (
+                    dict((await db.execute(select(Printer.id, Printer.name).where(Printer.is_active.is_(True)))).all())
+                    if enabled
+                    else {}
+                )
+
+            retry_capacity, self._retry_capacity = self._retry_capacity, False
+            if not retry_capacity:
+                self._capacity_devices.update(row.credential_key for row in rows if row.state == "capacity")
+            for provider in providers:
+                config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+                enabled = (
+                    provider.enabled
+                    and config.get("lock_screen_widgets") is True
+                    and not str(config.get("device_id", "")).strip().upper().startswith(("GRP", "WB", "MC"))
+                )
+                credential = _credential_key(config)
+                owned = [r for r in rows if r.provider_id == provider.id]
+                selected = {pid: name for pid, name in printers.items() if provider.printer_id in (None, pid)}
+                if len(selected) > 10:
+                    # Preserve already-owned widgets first; adding an eleventh
+                    # printer must not delete one merely because query order changed.
+                    retained = [row.printer_id for row in owned if row.printer_id in selected]
+                    chosen = list(dict.fromkeys([*retained, *sorted(selected)]))[:10]
+                    selected = {pid: selected[pid] for pid in chosen}
+                    if provider.id not in self._over_capacity_providers:
+                        logger.warning(
+                            "Notify widget provider %s exceeds ten active printers; select a printer", provider.id
+                        )
+                        self._over_capacity_providers.add(provider.id)
+                else:
+                    self._over_capacity_providers.discard(provider.id)
+                for row in owned:
+                    if retry_capacity and row.state == "capacity":
+                        row.state, row.failures, row.next_attempt_at = "pending", 0, None
+                        await self._save(row)
+                    if self._retired_row(row):
+                        continue
+                    if row.credential_key != credential:
+                        continue  # Credential-edit hook cleans up with the previous token.
+                    if row.state == "deleting" or not enabled or row.printer_id not in selected:
+                        await self._delete(row, config)
+                    elif row.state == "uncertain" and not row.failures:
+                        await self._failure(
+                            row, NotifyError("Previous creation was interrupted", delivery_state="unknown")
+                        )
+                    elif row.state not in {"uncertain", "suppressed", "capacity"}:
+                        await self._sync(
+                            row, config, widget_content(selected[row.printer_id], self._state(row.printer_id))
+                        )
+                if not enabled:
+                    continue
+                for printer_id, name in selected.items():
+                    if credential in self._capacity_devices:
+                        break
+                    if any(r.printer_id == printer_id for r in owned):
+                        continue
+                    row = NotificationLockScreenWidget(
+                        provider_id=provider.id,
+                        printer_id=printer_id,
+                        credential_key=credential,
+                        state="pending",
+                        content="{}",
+                        created_at=_now(),
+                        failures=0,
+                    )
+                    if self._retired_row(row):
+                        continue
+                    self._working_rows.append(row)
+                    async with self._session() as db:
+                        db.add(row)
+                        await db.commit()
+                    await self._sync(row, config, widget_content(name, self._state(printer_id)))
+
+            self._cleanup_pending = any(
+                row.state == "deleting" and row.provider_id in {p.id for p in providers} for row in rows
+            )
+
+    async def _sync(self, row, config, content):
+        if self._retired_row(row):
+            return
+        if row.next_attempt_at and row.next_attempt_at > _now():
+            return
+        encoded = json.dumps(content, ensure_ascii=False, sort_keys=True)
+        if row.widget_id and row.content == encoded:
+            return
+        api = await self._api()
+        create_attempted = False
+        try:
+            if row.widget_id:
+                await api.update_widget(row.widget_id, config.get("token", ""), content)
+            else:
+                # Device and widget IDs can both be eight characters. Prove
+                # the configured target is a device before POST: new=1 on a WG
+                # URL still addresses that existing (possibly unrelated) widget.
+                await api.list_widgets(config.get("device_id", ""), config.get("token", ""))
+                # If the process dies after this commit, the create may have
+                # happened. The next process must never repeat new=1 blindly.
+                row.state = "uncertain"
+                await self._save(row)
+                if self._retired_row(row):
+                    row.state = "pending"  # No create was sent; cleanup need not warn of an unknown widget.
+                    return
+                create_attempted = True
+                result = await api.create_widget(config.get("device_id", ""), config.get("token", ""), content)
+                row.widget_id = result["widgetId"]
+            row.state = "active"
+            row.content = encoded
+            row.failures = 0
+            row.last_sent_at = _now()
+            row.next_attempt_at = _now() + timedelta(seconds=_INTERVAL)
+            await self._save(row)
+            async with self._session() as db:
+                # A successful widget update must not clear a push/Live Activity error.
+                await db.execute(
+                    update(NotificationProvider)
+                    .where(
+                        NotificationProvider.id == row.provider_id,
+                        or_(
+                            NotificationProvider.last_error.startswith(_ERROR_PREFIX),
+                            and_(
+                                NotificationProvider.last_error.startswith(_CAPACITY_ERROR_PREFIX),
+                                ~select(NotificationLockScreenWidget.id)
+                                .where(
+                                    NotificationLockScreenWidget.provider_id == row.provider_id,
+                                    NotificationLockScreenWidget.state == "capacity",
+                                )
+                                .exists(),
+                            ),
+                        ),
+                    )
+                    .values(last_error=None, last_error_at=None)
+                )
+                await db.commit()
+        except NotifyError as error:
+            if not row.widget_id and not create_attempted:
+                # A failed read cannot have created anything. Do not adopt any
+                # WG that might appear in its response, and retry safely.
+                error = NotifyError(
+                    str(error),
+                    status_code=error.status_code,
+                    delivery_state="not-delivered",
+                    retry_after_seconds=error.retry_after_seconds or _INTERVAL,
+                )
+            await self._failure(row, error)
+
+    async def _failure(self, row, error):
+        deleting = row.state == "deleting"
+        widget_id = getattr(error, "widget_id", None)
+        if widget_id:
+            row.widget_id = widget_id
+            row.state = "active"
+        elif not row.widget_id and error.delivery_state == "unknown":
+            row.state = "uncertain"
+        elif error.status_code in (401, 403, 404):
+            row.state = "suppressed"
+        elif not row.widget_id:
+            capacity = error.status_code == 400 and "10" in str(error.payload.get("message", ""))
+            if capacity:
+                row.state = "capacity"
+                self._capacity_devices.add(row.credential_key)
+            elif error.status_code in (429, 503) or error.retry_after_seconds is not None:
+                row.state = "pending"
+            else:
+                row.state = "suppressed"
+        if deleting:
+            row.state = "deleting"
+        row.failures = min((row.failures or 0) + 1, 5)
+        delay = max(_INTERVAL * 2 ** (row.failures - 1), error.retry_after_seconds or 0)
+        row.next_attempt_at = _now() + timedelta(seconds=delay)
+        await self._save(row)
+        message = (_CAPACITY_ERROR_PREFIX if row.state == "capacity" else _ERROR_PREFIX) + str(error)
+        if row.state == "uncertain":
+            message += (
+                " Creation could not be confirmed. Check Notify! and remove any duplicate or unwanted widget, "
+                "then turn Lock Screen widgets off and on to retry."
+            )
+        elif row.state == "capacity":
+            message += " Free a widget slot in Notify! and save this provider to retry."
+        elif error.retry_after_seconds:
+            message += f" Retry after {error.retry_after_seconds} seconds."
+        async with self._session() as db:
+            await db.execute(
+                update(NotificationProvider)
+                .where(NotificationProvider.id == row.provider_id)
+                .values(
+                    last_error=message,
+                    last_error_at=_now(),
+                )
+            )
+            await db.commit()
+        logger.warning("Notify widget request failed for provider %s (HTTP %s)", row.provider_id, error.status_code)
+
+    async def _delete(self, row, config):
+        if row.state != "deleting":
+            row.state = "deleting"
+            await self._save(row)
+        if not row.widget_id:
+            await self._remove(row)
+            return
+        if row.failures and row.next_attempt_at and row.next_attempt_at > _now():
+            return
+        if row.widget_id:
+            try:
+                await (await self._api()).delete_widget(row.widget_id, config.get("token", ""))
+            except NotifyError as error:
+                if error.status_code == 403:
+                    # The API deliberately conflates a removed WG and invalid
+                    # credentials. Only an authenticated device list can prove
+                    # our exact widget is gone; never create/adopt from that list.
+                    try:
+                        existing = await (await self._api()).list_widgets(
+                            config.get("device_id", ""), config.get("token", "")
+                        )
+                    except NotifyError as listing_error:
+                        await self._failure(row, listing_error)
+                        return
+                    if any(widget.get("widgetId") == row.widget_id for widget in existing.get("widgets", [])):
+                        await self._failure(row, error)
+                        return
+                elif error.status_code != 404:
+                    await self._failure(row, error)
+                    return
+        await self._remove(row)
+
+    async def cleanup_provider(self, provider_id: int, old_config: dict, *, captured_rows=None):
+        """Transfer a token rotation, or clean up before old credentials vanish.
+
+        Widgets have no expiry. If a different-device/delete cleanup fails,
+        Notify's app must remove the saved widget manually. Never retain or log
+        its credential-bearing updateUrl.
+        """
+        async with self._lock:
+            async with self._session() as db:
+                current = await db.get(NotificationProvider, provider_id)
+                rows = (
+                    await db.scalars(
+                        select(NotificationLockScreenWidget).where(
+                            NotificationLockScreenWidget.provider_id == provider_id,
+                            NotificationLockScreenWidget.credential_key == _credential_key(old_config),
+                        )
+                    )
+                ).all()
+            if captured_rows is not None:
+                merged = {row.id: row for row in rows}
+                merged.update(
+                    {row.id: row for row in captured_rows if row.credential_key == _credential_key(old_config)}
+                )
+                rows = list(merged.values())
+            config = (
+                (json.loads(current.config) if isinstance(current.config, str) else current.config) if current else {}
+            )
+            same_device = (
+                current
+                and current.provider_type == "notify"
+                and str(config.get("device_id", "")).strip() == str(old_config.get("device_id", "")).strip()
+                and _credential_key(config) != _credential_key(old_config)
+            )
+            if same_device:
+                # A rotated token still addresses the same device and WG. Never
+                # delete/recreate its permanent widget, or revive an uncertain
+                # create whose identifier we never received.
+                for row in rows:
+                    row.credential_key = _credential_key(config)
+                    row.failures, row.next_attempt_at = 0, None
+                    if row.widget_id and row.state == "suppressed":
+                        row.state = "active"
+                        row.content = "{}"  # Verify the new token even if printer content is unchanged.
+                    await self._save(row)
+                return
+            cleanup_failed = False
+            for row in rows:
+                if row.state == "deleted":
+                    continue
+                if row.widget_id:
+                    try:
+                        await (await self._api()).delete_widget(row.widget_id, old_config.get("token", ""))
+                    except NotifyError:
+                        cleanup_failed = True
+                elif row.state == "uncertain":
+                    cleanup_failed = True
+                await self._remove(row)
+            if cleanup_failed:
+                logger.warning(
+                    "Notify widget cleanup failed for provider %s; remove old widgets in Notify!", provider_id
+                )
+                # Keep this distinct from transient widget-send errors: success
+                # on the new device does not prove an old permanent widget gone.
+                async with self._session() as db:
+                    await db.execute(
+                        update(NotificationProvider)
+                        .where(NotificationProvider.id == provider_id)
+                        .values(
+                            last_error="Notify! widget cleanup: Remove the previous device's Bambuddy widgets in Notify!; automatic cleanup could not be confirmed.",
+                            last_error_at=_now(),
+                        )
+                    )
+                    await db.commit()
+
+
+notify_widgets = NotifyWidgetService()

+ 192 - 0
backend/app/services/notify_worker.py

@@ -0,0 +1,192 @@
+"""Small lifecycle shared by the two opt-in Notify background workers.
+
+Provider edits invalidate a cached enable flag. An unused integration therefore
+has no periodic database work. Cleanup captures ownership without waiting for the
+worker's HTTP/lock, then drains in the background; live row references retain a
+resource ID returned by an already-running create after its provider is deleted.
+"""
+
+import asyncio
+import json
+import logging
+
+from sqlalchemy import delete, select
+
+from backend.app.models.notification import NotificationProvider
+
+logger = logging.getLogger(__name__)
+
+
+class NotifyWorkerLifecycle:
+    ownership_model = None
+    feature_field = ""
+    worker_interval = 60
+    worker_name = "notify-worker"
+    stopped_states = ("ended", "deleted")
+
+    def _init_worker(self):
+        self._provider_enabled: bool | None = None
+        self._providers_dirty = True
+        self._cleanup_once = False
+        self._cleanup_pending = False
+        self._initial_discovery = True
+        self._change_epoch = 0
+        self._retry_delay = 1
+        self._wake = asyncio.Event()
+        self._cleanup_jobs: dict[tuple[int, str], tuple[dict, list]] = {}
+        self._retired: set[tuple[int, str]] = set()
+        self._working_rows: list = []
+
+    def _eligible(self, provider):
+        config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
+        return bool(
+            provider.enabled
+            and config.get(self.feature_field) is True
+            and not str(config.get("device_id", "")).strip().upper().startswith(("GRP", "WB", "MC"))
+        )
+
+    def _on_activated(self):
+        pass
+
+    def providers_changed(self):
+        """Fast notification after provider CRUD; performs no DB or HTTP work."""
+        self._providers_dirty = True
+        self._cleanup_once = True
+        self._change_epoch += 1
+        self._wake.set()
+
+    def _retired_row(self, row):
+        return (row.provider_id, row.credential_key) in self._retired
+
+    async def schedule_cleanup(self, provider_id, old_config):
+        """Capture cleanup before DELETE commits, or after a credential PATCH.
+
+        Only a short database read is awaited. Never cancel an in-flight create:
+        its response may be our only opportunity to learn the remote handle.
+        """
+        credential = self._cleanup_key(old_config)
+        key = (provider_id, credential)
+        self._retired.add(key)
+        try:
+            async with self._session() as db:
+                persisted = (
+                    await db.scalars(
+                        select(self.ownership_model).where(
+                            self.ownership_model.provider_id == provider_id,
+                            self.ownership_model.credential_key == credential,
+                        )
+                    )
+                ).all()
+            previous = self._cleanup_jobs.get(key, ({}, []))[1]
+            # Prefer the mutable worker object for each row. A create already
+            # awaiting HTTP will assign its returned ID on this same object.
+            captured = {row.id: row for row in [*persisted, *previous]}
+            for row in self._working_rows:
+                if row.provider_id == provider_id and row.credential_key == credential:
+                    captured[row.id] = row
+            self._cleanup_jobs[key] = (dict(old_config), list(captured.values()))
+        except BaseException:
+            if key not in self._cleanup_jobs:
+                self._retired.discard(key)
+            raise
+        self.providers_changed()
+        self.start()
+
+    async def _purge_orphans(self):
+        """Drop ownership rows whose provider no longer exists.
+
+        SQLite does not enforce the provider cascade, and queued cleanup lives
+        only in memory. A restart before it drains leaves rows behind, which a
+        new provider reusing the same id would inherit. Their credentials are
+        gone, so the remote resources can no longer be removed from here.
+        """
+        orphaned = self.ownership_model.provider_id.not_in(select(NotificationProvider.id))
+        async with self._session() as db:
+            if await db.scalar(select(self.ownership_model.id).where(orphaned).limit(1)) is None:
+                return
+            result = await db.execute(delete(self.ownership_model).where(orphaned))
+            await db.commit()
+        logger.info("%s removed %d rows left by deleted providers", self.worker_name, result.rowcount)
+
+    def start(self):
+        if self._task is None or self._task.done():
+            self._task = asyncio.create_task(self._run(), name=self.worker_name)
+
+    async def close(self):
+        if self._task:
+            self._task.cancel()
+            try:
+                await self._task
+            except asyncio.CancelledError:
+                pass
+            self._task = None
+        if self._http:
+            await self._http.aclose()
+            self._http = None
+            self._client = None
+
+    async def _run(self):
+        while True:
+            self._wake.clear()
+            change_epoch = self._change_epoch
+            try:
+                if self._providers_dirty:
+                    self._providers_dirty = False
+                    if self._initial_discovery:
+                        await self._purge_orphans()
+                    async with self._session() as db:
+                        providers = (
+                            await db.scalars(
+                                select(NotificationProvider).where(NotificationProvider.provider_type == "notify")
+                            )
+                        ).all()
+                    previously_enabled = self._provider_enabled
+                    self._provider_enabled = any(self._eligible(provider) for provider in providers)
+                    if self._provider_enabled and previously_enabled is False:
+                        self._on_activated()
+                    if self._initial_discovery and not self._provider_enabled and providers:
+                        async with self._session() as db:
+                            pending = await db.scalar(
+                                select(self.ownership_model.id)
+                                .where(
+                                    self.ownership_model.provider_id.in_([provider.id for provider in providers]),
+                                    self.ownership_model.state.not_in(self.stopped_states),
+                                )
+                                .limit(1)
+                            )
+                        self._cleanup_pending = pending is not None
+                    self._initial_discovery = False
+                while self._cleanup_jobs:
+                    key = next(iter(self._cleanup_jobs))
+                    old_config, rows = self._cleanup_jobs.pop(key)
+                    try:
+                        await self.cleanup_provider(key[0], old_config, captured_rows=rows)
+                    except Exception:
+                        pending = self._cleanup_jobs.get(key, (old_config, []))[1]
+                        self._cleanup_jobs[key] = (old_config, [*rows, *pending])
+                        raise
+                    finally:
+                        if key not in self._cleanup_jobs:
+                            self._retired.discard(key)
+                if self._provider_enabled or self._cleanup_once or self._cleanup_pending:
+                    await self.tick()
+                if change_epoch == self._change_epoch:
+                    self._cleanup_once = False
+                self._retry_delay = 1
+            except asyncio.CancelledError:
+                raise
+            except Exception:
+                logger.exception("%s reconciliation failed", self.worker_name)
+                self._providers_dirty = True
+                await asyncio.sleep(self._retry_delay)
+                self._retry_delay = min(self._retry_delay * 2, 30)
+                continue
+            if self._providers_dirty or self._cleanup_jobs:
+                continue
+            if self._provider_enabled or self._cleanup_pending:
+                try:
+                    await asyncio.wait_for(self._wake.wait(), timeout=self.worker_interval)
+                except TimeoutError:
+                    pass
+            else:
+                await self._wake.wait()

+ 203 - 0
backend/tests/integration/test_notify_provider_api.py

@@ -0,0 +1,203 @@
+"""Notify configuration validation and lifecycle cleanup through the public API."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
+from backend.app.services.notify_live_activities import _credential_key
+
+CONFIG = {
+    "device_id": "IO12345678901234",
+    "token": "example-token",
+    "live_activities": True,
+    "lock_screen_widgets": True,
+}
+
+
+async def create_provider(client):
+    response = await client.post(
+        "/api/v1/notifications/", json={"name": "Notify", "provider_type": "notify", "config": CONFIG}
+    )
+    assert response.status_code == 200
+    return response.json()["id"]
+
+
+async def test_notify_settings_round_trip_and_push_flags_remain_independent(async_client):
+    provider_id = await create_provider(async_client)
+    response = await async_client.get(f"/api/v1/notifications/{provider_id}")
+    assert response.json()["config"] == CONFIG
+    assert response.json()["on_print_start"] is False
+    assert response.json()["on_print_progress"] is False
+
+
+async def test_notify_crud_refreshes_worker_configuration_without_remote_work(async_client):
+    with (
+        patch("backend.app.api.routes.notifications.notify_live_activities.providers_changed") as live_changed,
+        patch("backend.app.api.routes.notifications.notify_widgets.providers_changed") as widgets_changed,
+        patch("backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock),
+        patch("backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock),
+        patch(
+            "backend.app.api.routes.notifications.notify_live_activities.cleanup_provider", new_callable=AsyncMock
+        ) as live_http,
+        patch(
+            "backend.app.api.routes.notifications.notify_widgets.cleanup_provider", new_callable=AsyncMock
+        ) as widget_http,
+    ):
+        provider_id = await create_provider(async_client)
+        assert live_changed.call_count == widgets_changed.call_count == 1
+        response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json={"enabled": False})
+        assert response.status_code == 200
+        assert live_changed.call_count == widgets_changed.call_count == 2
+        response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
+        assert response.status_code == 200
+        assert live_changed.call_count == widgets_changed.call_count == 3
+    live_http.assert_not_awaited()
+    widget_http.assert_not_awaited()
+
+
+@pytest.mark.parametrize(
+    "config",
+    [
+        {},
+        {**CONFIG, "device_id": "GRP12345"},
+        {**CONFIG, "device_id": "WB12345678901234"},
+        {**CONFIG, "live_activities": "false"},
+        {**CONFIG, "live_activities": False, "lock_screen_widgets": "false"},
+        {**CONFIG, "device_id": "GRP12345", "live_activities": False},
+        {**CONFIG, "device_id": "WB12345678901234", "live_activities": False},
+    ],
+)
+async def test_invalid_notify_configuration_cannot_be_created(async_client, config):
+    response = await async_client.post(
+        "/api/v1/notifications/", json={"name": "Notify", "provider_type": "notify", "config": config}
+    )
+    assert response.status_code == 422
+
+
+async def test_empty_config_update_is_rejected_and_keeps_previous_credentials(async_client):
+    provider_id = await create_provider(async_client)
+    response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json={"config": {}})
+    assert response.status_code == 422
+    saved = await async_client.get(f"/api/v1/notifications/{provider_id}")
+    assert saved.json()["config"] == CONFIG
+
+
+async def test_credential_change_queues_old_device_cleanup_with_old_credentials(async_client):
+    provider_id = await create_provider(async_client)
+    with (
+        patch(
+            "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
+        ) as cleanup,
+        patch(
+            "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
+        ) as widget_cleanup,
+    ):
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider_id}", json={"config": {**CONFIG, "token": "replacement-token"}}
+        )
+    assert response.status_code == 200
+    cleanup.assert_awaited_once_with(provider_id, CONFIG)
+    widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
+    assert response.json()["config"]["token"] == "replacement-token"
+
+
+async def test_disabling_or_changing_push_thread_leaves_cleanup_to_durable_worker(async_client):
+    provider_id = await create_provider(async_client)
+    with (
+        patch(
+            "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
+        ) as cleanup,
+        patch(
+            "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
+        ) as widget_cleanup,
+    ):
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider_id}",
+            json={"enabled": False, "config": {**CONFIG, "group_type": "workshop"}},
+        )
+    assert response.status_code == 200
+    cleanup.assert_not_awaited()
+    widget_cleanup.assert_not_awaited()
+
+
+async def test_delete_queues_cleanup_before_forgetting_credentials(async_client):
+    provider_id = await create_provider(async_client)
+    with (
+        patch(
+            "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
+        ) as cleanup,
+        patch(
+            "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
+        ) as widget_cleanup,
+    ):
+        response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
+    assert response.status_code == 200
+    cleanup.assert_awaited_once_with(provider_id, CONFIG)
+    widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
+    assert (await async_client.get(f"/api/v1/notifications/{provider_id}")).status_code == 404
+
+
+async def test_lock_screen_widgets_can_be_enabled_without_live_activities_or_push_events(async_client):
+    config = {**CONFIG, "live_activities": False}
+    response = await async_client.post(
+        "/api/v1/notifications/",
+        json={
+            "name": "Printer widget",
+            "provider_type": "notify",
+            "config": config,
+            "on_print_start": False,
+            "on_print_complete": False,
+            "on_print_failed": False,
+        },
+    )
+    assert response.status_code == 200
+    assert response.json()["config"] == config
+    assert response.json()["on_print_start"] is False
+    assert response.json()["on_print_complete"] is False
+    assert response.json()["on_print_failed"] is False
+
+
+async def test_switching_provider_type_cleans_up_both_owned_notify_resources(async_client):
+    provider_id = await create_provider(async_client)
+    with (
+        patch(
+            "backend.app.api.routes.notifications.notify_live_activities.schedule_cleanup", new_callable=AsyncMock
+        ) as cleanup,
+        patch(
+            "backend.app.api.routes.notifications.notify_widgets.schedule_cleanup", new_callable=AsyncMock
+        ) as widget_cleanup,
+    ):
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider_id}",
+            json={"provider_type": "ntfy", "config": {"topic": "prints"}},
+        )
+    assert response.status_code == 200
+    cleanup.assert_awaited_once_with(provider_id, CONFIG)
+    widget_cleanup.assert_awaited_once_with(provider_id, CONFIG)
+
+
+@pytest.mark.parametrize("disable", [{"config": {**CONFIG, "lock_screen_widgets": False}}, {"enabled": False}])
+async def test_quick_widget_disable_and_reenable_preserves_cleanup_intent(async_client, db_session, disable):
+    provider_id = await create_provider(async_client)
+    row = NotificationLockScreenWidget(
+        provider_id=provider_id,
+        printer_id=42,
+        credential_key=_credential_key(CONFIG),
+        widget_id="WG123456",
+        state="active",
+        content="{}",
+    )
+    db_session.add(row)
+    await db_session.commit()
+    response = await async_client.patch(f"/api/v1/notifications/{provider_id}", json=disable)
+    assert response.status_code == 200
+    await db_session.refresh(row)
+    assert row.state == "deleting"
+    response = await async_client.patch(
+        f"/api/v1/notifications/{provider_id}", json={"config": CONFIG, "enabled": True}
+    )
+    assert response.status_code == 200
+    await db_session.refresh(row)
+    assert row.state == "deleting"
+    assert row.widget_id == "WG123456"

+ 317 - 0
backend/tests/unit/services/test_notify_client.py

@@ -0,0 +1,317 @@
+"""Notify wire contracts, including failures that must not create duplicate tiles."""
+
+import json
+import logging
+from unittest.mock import AsyncMock, patch
+
+import httpx
+import pytest
+
+from backend.app.models.notification import NotificationProvider
+from backend.app.schemas.notification import NotificationProviderCreate
+from backend.app.services.notification_service import NotificationService
+from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials
+
+
+@pytest.mark.parametrize(
+    "device_id", ["ABC12345", "IO12345678901234", "WB12345678901234", "MC12345678901234", "GRP12345"]
+)
+async def test_push_uses_unified_json_endpoint(device_id):
+    def handle(request):
+        assert request.method == "POST"
+        assert request.url.path == f"/notify-json/{device_id}"
+        assert request.url.params["token"] == "a+b&c"
+        assert request.headers["content-type"] == "application/json"
+        expected = {
+            "title": "Print complete",
+            "text": "Pièce terminée 🖨",
+            "groupType": "bambuddy",
+            "iconUrl": "https://example.com/icon.png",
+            "imageUrl": "https://example.com/photo.jpg",
+        }
+        if device_id.startswith(("WB", "GRP")):
+            expected.pop("imageUrl")
+        assert json.loads(request.content) == expected
+        return httpx.Response(200, json={"success": True})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        await NotifyClient(http).send_notification(
+            device_id,
+            "a+b&c",
+            title="Print complete",
+            text="Pièce terminée 🖨",
+            group_type="bambuddy",
+            icon_url="https://example.com/icon.png",
+            image_url="https://example.com/photo.jpg",
+        )
+
+
+async def test_photo_on_http_only_installation_still_delivers_text():
+    def handle(request):
+        assert "imageUrl" not in json.loads(request.content)
+        return httpx.Response(200, json={"success": True})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        await NotifyClient(http).send_notification(
+            "ABC12345", "secret", title="Done", text="Done", image_url="http://lan/photo"
+        )
+
+
+async def test_activity_start_is_explicit_and_updates_use_precise_id():
+    requests = []
+
+    def handle(request):
+        requests.append(request)
+        return httpx.Response(200, json={"success": True, "activityId": "LA123456", "pushed": False})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        client = NotifyClient(http)
+        await client.start_activity("ABC12345", "secret", {"title": "Printer", "endsIn": 3600})
+        result = await client.update_activity("LA123456", "secret", {"progress": 55, "endsIn": None})
+        await client.end_activity("LA123456", "secret", {"progress": 100, "keepFor": 300})
+    assert requests[0].url.params["new"] == "1"
+    assert requests[1].url.path == requests[2].url.path == "/live-activity/LA123456"
+    assert "new" not in requests[1].url.params
+    assert json.loads(requests[1].content)["endsIn"] is None
+    assert requests[2].method == "DELETE"
+    assert result["pushed"] is False  # Stored while the tile token rotates: not a failed update.
+
+
+@pytest.mark.parametrize(
+    ("status", "body", "headers", "retry", "activity_id"),
+    [
+        (502, {"deliveryState": "unknown", "activityId": "LA123456"}, {}, None, "LA123456"),
+        (502, {"deliveryState": "not-delivered", "retryAfterSeconds": 900}, {}, 900, None),
+        (429, {"retryAfterSeconds": 1043, "openingTheAppMayHelp": False}, {}, 1043, None),
+        (429, {}, {"Retry-After": "60"}, 60, None),
+        (400, {}, {}, None, None),
+        (410, {"endReason": "dismissed"}, {}, None, None),
+    ],
+)
+async def test_gateway_recovery_hints_are_preserved_without_retrying(status, body, headers, retry, activity_id):
+    calls = []
+
+    def handle(request):
+        calls.append(request)
+        return httpx.Response(status, json={**body, "message": "secret"}, headers=headers)
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).start_activity("ABC12345", "secret", {"title": "Printer"})
+    assert len(calls) == 1
+    assert error.value.status_code == status
+    assert error.value.retry_after_seconds == retry
+    assert error.value.activity_id == activity_id
+    assert error.value.delivery_state == body.get("deliveryState")
+    assert "secret" not in str(error.value)
+
+
+async def test_transport_exception_is_opaque_and_start_is_uncertain():
+    def handle(request):
+        raise httpx.ReadTimeout(f"Timed out: {request.url}", request=request)
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).start_activity("ABC12345", "secret", {"title": "Printer"})
+    assert error.value.delivery_state == "unknown"
+    assert "secret" not in str(error.value)
+    assert error.value.__suppress_context__
+
+
+async def test_httpx_logs_redact_query_token(caplog):
+    caplog.set_level(logging.INFO, logger="httpx")
+    async with httpx.AsyncClient(
+        transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"success": True}))
+    ) as http:
+        await NotifyClient(http).send_notification("ABC12345", "top-secret", title="Done", text="Done")
+    assert "top-secret" not in caplog.text
+    assert "token=[REDACTED]" in caplog.text
+
+
+async def test_end_never_accepts_device_dialect():
+    http = AsyncMock(spec=httpx.AsyncClient)
+    with pytest.raises(NotifyError, match="precise activity ID"):
+        await NotifyClient(http).end_activity("ABC12345", "secret")
+    http.request.assert_not_called()
+
+
+@pytest.mark.parametrize("body", [{}, [], {"success": True}, {"success": True, "activityId": "bad/id"}])
+async def test_start_missing_handle_is_uncertain(body):
+    async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=body))) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).start_activity("ABC12345", "secret", {"title": "Printer"})
+    assert error.value.delivery_state == "unknown"
+
+
+@pytest.mark.parametrize("body", [{"success": False}, {"success": True, "failureCount": 2}, {"message": "ok"}])
+async def test_push_does_not_claim_delivery_for_application_errors_or_partial_groups(body):
+    async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=body))) as http:
+        with pytest.raises(NotifyError):
+            await NotifyClient(http).send_notification("GRP12345", "secret", title="Done", text="Done")
+
+
+async def test_rejected_browser_push_does_not_suggest_live_activity_setup():
+    async with httpx.AsyncClient(
+        transport=httpx.MockTransport(lambda _: httpx.Response(400, json={"error": "bad"}))
+    ) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).send_notification(
+                "WB12345678901234", "secret", title="Error", text="Printer error"
+            )
+    assert "Live Activity" not in str(error.value)
+    assert "device registration" in str(error.value)
+
+
+@pytest.mark.parametrize(
+    "config",
+    [
+        {"device_id": "../../etc", "token": "secret"},
+        {"device_id": "ABC12345", "token": ""},
+        {"device_id": "GRP12345", "token": "secret", "live_activities": True},
+    ],
+)
+def test_invalid_credentials_and_group_live_activities_are_rejected(config):
+    with pytest.raises(NotifyError):
+        notify_credentials(config)
+
+
+@pytest.mark.parametrize(
+    "extra",
+    [
+        {"live_activities": "false"},
+        {"live_activity_privacy": "true"},
+        {"icon_url": "http://example.com/icon.png"},
+        {"live_activity_button_url": "https://user:secret@example.com"},
+        {"live_activity_style": "broken"},
+        {"live_activity_tint": "green"},
+        {"live_activity_metrics": "layers"},
+        {"live_activity_metrics": [["layers"]]},
+        {"device_id": "WB12345678901234", "live_activities": True},
+        {"device_id": "MC12345678901234", "live_activities": True},
+    ],
+)
+def test_invalid_display_config_is_rejected_before_it_reaches_worker(extra):
+    with pytest.raises(NotifyError):
+        notify_credentials({"device_id": "ABC12345", "token": "secret", **extra})
+
+
+@pytest.mark.parametrize("device_id", ["GRP12345", "WB12345678901234", "MC12345678901234"])
+def test_non_ios_targets_remain_eligible_for_push_notifications(device_id):
+    assert notify_credentials({"device_id": device_id, "token": "secret", "live_activities": False}) == (
+        device_id,
+        "secret",
+    )
+
+
+async def test_notify_provider_dispatch_and_test_use_existing_photo_toggle():
+    service = NotificationService()
+    provider = NotificationProvider(
+        name="Notify",
+        provider_type="notify",
+        config=json.dumps({"device_id": "ABC12345", "token": "secret"}),
+        attach_photo=False,
+        quiet_hours_enabled=False,
+    )
+    with patch.object(service, "_send_notify", new_callable=AsyncMock, return_value=(True, "sent")) as send:
+        await service._send_to_provider(provider, "Title", "Body", image_data=b"image")
+        send.assert_awaited_once_with(
+            json.loads(provider.config), "Title", "Body", image_url=None, event_type=None, printer_id=None
+        )
+        send.reset_mock()
+        await service.send_test_notification("notify", json.loads(provider.config), attach_photo=False)
+        assert send.await_count == 1
+        assert send.call_args.kwargs["image_url"] is None
+
+
+@pytest.mark.parametrize("device_id", ["GRP12345", "WB12345678901234", "grp12345", "wb12345678901234"])
+async def test_browser_and_group_error_alerts_never_publish_or_reuse_camera_photos(device_id):
+    service = NotificationService()
+    config = {"device_id": device_id, "token": "secret"}
+    provider = NotificationProvider(
+        name="Notify", provider_type="notify", config=json.dumps(config), attach_photo=True, quiet_hours_enabled=False
+    )
+    with (
+        patch.object(service, "_send_notify", new_callable=AsyncMock, return_value=(True, "sent")) as send,
+        patch.object(service, "_get_or_build_photo_url", new_callable=AsyncMock) as photo,
+        patch("backend.app.services.notification_service._load_sample_notification_image") as sample,
+    ):
+        await service._send_to_provider(
+            provider,
+            "Printer error",
+            "Filament runout",
+            image_data=b"camera",
+            event_type="printer_error",
+            photo_cache={"url": "https://example.com/camera.jpg"},
+            printer_id=42,
+        )
+        assert send.call_args.kwargs["image_url"] is None
+        await service.send_test_notification("notify", config, attach_photo=True)
+        assert send.call_args.kwargs["image_url"] is None
+    photo.assert_not_awaited()
+    sample.assert_not_called()
+
+
+async def test_ios_error_alert_includes_camera_photo_when_enabled():
+    service = NotificationService()
+    config = {"device_id": "IO12345678901234", "token": "secret"}
+    provider = NotificationProvider(
+        name="Notify", provider_type="notify", config=json.dumps(config), attach_photo=True, quiet_hours_enabled=False
+    )
+    with (
+        patch.object(service, "_send_notify", new_callable=AsyncMock, return_value=(True, "sent")) as send,
+        patch.object(
+            service, "_get_or_build_photo_url", new_callable=AsyncMock, return_value="https://example.com/camera.jpg"
+        ) as photo,
+    ):
+        await service._send_to_provider(
+            provider,
+            "Printer error",
+            "Filament runout",
+            image_data=b"camera",
+            event_type="printer_error",
+            printer_id=42,
+        )
+        photo.assert_awaited_once()
+        assert send.call_args.kwargs["image_url"] == "https://example.com/camera.jpg"
+
+
+async def test_notify_push_still_respects_quiet_hours():
+    service = NotificationService()
+    provider = NotificationProvider(name="Notify", provider_type="notify", config="{}")
+    with (
+        patch.object(service, "_is_in_quiet_hours", return_value=True),
+        patch.object(service, "_send_notify", new_callable=AsyncMock) as send,
+    ):
+        result = await service._send_to_provider(provider, "Title", "Body")
+    send.assert_not_awaited()
+    assert result == (True, "Skipped - quiet hours")
+
+
+def test_provider_schema_accepts_notify():
+    provider = NotificationProviderCreate(
+        name="Notify", provider_type="notify", config={"device_id": "ABC12345", "token": "x"}
+    )
+    assert provider.provider_type == "notify"
+
+
+@pytest.mark.parametrize(
+    ("event", "time_sensitive"),
+    [
+        ("print_failed", True),
+        ("print_stopped", True),
+        ("printer_error", True),
+        ("print_complete", False),
+        (None, False),
+    ],
+)
+async def test_problem_alert_priority_and_per_printer_thread(event, time_sensitive):
+    service = NotificationService()
+    config = {"device_id": "ABC12345", "token": "secret", "time_sensitive": True}
+    with patch.object(NotifyClient, "send_notification", new_callable=AsyncMock) as send:
+        try:
+            success, _ = await service._send_notify(config, "Title", "Body", event_type=event, printer_id=42)
+        finally:
+            await service.close()
+    assert success
+    assert send.call_args.kwargs["time_sensitive"] is time_sensitive
+    assert send.call_args.kwargs["group_type"] == "bambuddy-printer-42"

+ 896 - 0
backend/tests/unit/services/test_notify_live_activities.py

@@ -0,0 +1,896 @@
+"""Lifecycle protocol tests with real persistence and a fake Notify gateway."""
+
+import json
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from sqlalchemy import delete, event, select, text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401
+from backend.app.core.database import Base
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_live_activity import NotificationLiveActivity
+from backend.app.models.printer import Printer
+from backend.app.services import notify_live_activities as module
+from backend.app.services.notify_client import NotifyError
+from backend.app.services.notify_live_activities import NotifyLiveActivityService, PrintSnapshot
+
+
+@pytest.fixture
+async def setup(tmp_path, monkeypatch):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'activities.db'}")
+
+    @event.listens_for(engine.sync_engine, "connect")
+    def enable_foreign_keys(connection, _):
+        cursor = connection.cursor()
+        cursor.execute("PRAGMA foreign_keys=ON")
+        cursor.close()
+
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    factory = async_sessionmaker(engine, expire_on_commit=False)
+    async with factory() as db:
+        db.add(Printer(id=1, name="P1", serial_number="serial1", ip_address="127.0.0.1", access_code="12345678"))
+        db.add(
+            NotificationProvider(
+                id=1,
+                name="Phone",
+                provider_type="notify",
+                enabled=True,
+                config=json.dumps({"device_id": "ABC12345", "token": "secret", "live_activities": True}),
+                on_print_start=False,
+                on_print_progress=False,
+                on_print_complete=False,
+                daily_digest_enabled=True,
+            )
+        )
+        await db.commit()
+    api = SimpleNamespace(
+        start_activity=AsyncMock(return_value={"activityId": "LA123456"}),
+        get_activity=AsyncMock(return_value={"state": "active"}),
+        update_activity=AsyncMock(return_value={"success": True}),
+        end_activity=AsyncMock(return_value={"success": True}),
+    )
+    clock = [datetime(2026, 1, 1, 12)]
+    monkeypatch.setattr(module, "_now", lambda: clock[0])
+    service = NotifyLiveActivityService(factory, api)
+    monkeypatch.setattr(service, "_quiet", lambda provider: False)
+    yield service, api, factory, clock
+    await engine.dispose()
+
+
+def state(**values):
+    return (
+        SimpleNamespace(
+            connected=True,
+            state="RUNNING",
+            subtask_id="job1",
+            subtask_name="Test print",
+            current_print="test.3mf",
+            progress=42,
+            remaining_time=15,
+            layer_num=42,
+            total_layers=100,
+            raw_data={},
+            hms_errors=[],
+            temperatures={"nozzle": 210, "bed": 60},
+            stg_cur=-1,
+            **values,
+        )
+        if not values
+        else SimpleNamespace(
+            **{
+                **vars(state()),
+                **values,
+            }
+        )
+    )
+
+
+async def rows(factory):
+    async with factory() as db:
+        return (await db.scalars(select(NotificationLiveActivity))).all()
+
+
+async def start(setup):
+    service, api, factory, clock = setup
+    service.observe(1, state())
+    await service.tick()
+    return service, api, factory, clock
+
+
+@pytest.mark.asyncio
+async def test_start_independent_of_push_digest_and_group_thread(setup):
+    service, api, factory, clock = setup
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config = json.loads(provider.config)
+        config["group_type"] = "Workshop"
+        provider.config = json.dumps(config)
+        await db.commit()
+    await start(setup)
+    assert api.start_activity.await_count == 1
+    assert (await rows(factory))[0].activity_id == "LA123456"
+    await service.tick()
+    assert api.start_activity.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_startup_drops_rows_of_provider_deleted_before_cleanup_drained(setup):
+    service, api, factory, clock = await start(setup)
+    assert len(await rows(factory)) == 1
+    # Production SQLite does not enforce the cascade.
+    async with factory() as db:
+        await db.execute(text("PRAGMA foreign_keys=OFF"))
+        await db.execute(delete(NotificationProvider).where(NotificationProvider.id == 1))
+        await db.commit()
+        await db.execute(text("PRAGMA foreign_keys=ON"))
+    restarted = NotifyLiveActivityService(factory, api)
+    await restarted._purge_orphans()
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_restart_reuses_exact_id_without_new_start(setup, monkeypatch):
+    service, api, factory, clock = await start(setup)
+    clock[0] += timedelta(minutes=2)
+    restarted = NotifyLiveActivityService(factory, api)
+    monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
+    restarted.observe(1, state(progress=46))
+    await restarted.tick()
+    api.get_activity.assert_awaited_once_with("LA123456", "secret")
+    assert api.start_activity.await_count == 1
+    assert api.update_activity.call_args.args[0] == "LA123456"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("phase,connected,expected", [("PAUSE", True, "Paused"), ("RUNNING", False, "Printer offline")])
+async def test_pause_disconnect_clear_countdown_then_resume(setup, phase, connected, expected):
+    service, api, factory, clock = await start(setup)
+    service.observe(1, state(state=phase, connected=connected))
+    await service.tick()
+    content = api.update_activity.call_args.args[2]
+    assert content["endsIn"] is None
+    assert content["status"] == expected
+    service.observe(1, state())
+    await service.tick()
+    assert api.update_activity.call_args.args[2]["endsIn"] == 900
+    assert api.start_activity.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_frozen_eta_does_not_drift(setup):
+    service, api, factory, clock = await start(setup)
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert api.update_activity.call_args.args[2]["endsIn"] == 780
+    service.observe(1, state(remaining_time=10))
+    clock[0] += timedelta(minutes=1)
+    await service.tick()
+    assert api.update_activity.call_args.args[2]["endsIn"] == 600
+
+
+@pytest.mark.asyncio
+async def test_quiet_hours_block_starts_but_not_updates_or_ends(setup, monkeypatch):
+    service, api, factory, clock = setup
+    monkeypatch.setattr(service, "_quiet", lambda provider: True)
+    service.observe(1, state())
+    await service.tick()
+    api.start_activity.assert_not_awaited()
+    monkeypatch.setattr(service, "_quiet", lambda provider: False)
+    await service.tick()
+    monkeypatch.setattr(service, "_quiet", lambda provider: True)
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    api.update_activity.assert_awaited_once()
+    service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
+    await service.tick()
+    assert api.end_activity.call_args.args[2]["progress"] == 100
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reason", ["dismissed", "dead-token", "never-started", "script"])
+async def test_terminal_suppression_survives_restart(setup, monkeypatch, reason):
+    service, api, factory, clock = await start(setup)
+    api.get_activity.return_value = {"state": "dismissed" if reason == "dismissed" else "ended", "endReason": reason}
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert (await rows(factory))[0].state == "suppressed"
+    restarted = NotifyLiveActivityService(factory, api)
+    monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
+    restarted.observe(1, state())
+    await restarted.tick()
+    assert api.start_activity.await_count == 1
+    restarted.observe(1, state(subtask_id="job2"))
+    await restarted.tick()
+    assert api.start_activity.await_count == 2
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reason", ["expired", "overdue", "abandoned"])
+async def test_expiry_rolls_over_only_for_live_print(setup, reason):
+    service, api, factory, clock = await start(setup)
+    api.get_activity.return_value = {"state": "ended", "endReason": reason}
+    clock[0] += timedelta(hours=8)
+    api.start_activity.return_value = {"activityId": "LA654321"}
+    await service.tick()
+    assert api.start_activity.await_count == 2
+    assert (await rows(factory))[0].activity_id == "LA654321"
+
+
+@pytest.mark.asyncio
+async def test_eight_hour_ceiling_polls_before_rollover(setup):
+    service, api, factory, clock = await start(setup)
+    clock[0] += timedelta(hours=8)
+    await service.tick()
+    api.get_activity.assert_awaited_once()
+    api.end_activity.assert_awaited_once_with("LA123456", "secret")
+    assert api.start_activity.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_ambiguous_start_with_id_is_reconciled_without_duplication(setup):
+    service, api, factory, clock = setup
+    api.start_activity.side_effect = NotifyError(
+        "unknown", status_code=502, payload={"activityId": "LA123456", "deliveryState": "unknown"}
+    )
+    await start(setup)
+    assert (await rows(factory))[0].activity_id == "LA123456"
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    api.get_activity.assert_awaited_once_with("LA123456", "secret")
+    assert api.start_activity.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_ambiguous_start_without_id_never_repeats(setup):
+    service, api, factory, clock = setup
+    api.start_activity.side_effect = NotifyError("timeout", delivery_state="unknown")
+    await start(setup)
+    clock[0] += timedelta(hours=2)
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    assert (await rows(factory))[0].state == "suppressed"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "error",
+    [
+        NotifyError("full", status_code=400, payload={"message": "Maximum 5 activities"}),
+        NotifyError("throttled", status_code=429, retry_after_seconds=1800),
+        NotifyError("not delivered", status_code=502, retry_after_seconds=1800, delivery_state="not-delivered"),
+        NotifyError("unavailable", status_code=503),
+    ],
+)
+async def test_retryable_starts_honor_backoff(setup, error):
+    service, api, factory, clock = setup
+    api.start_activity.side_effect = error
+    await start(setup)
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    clock[0] += timedelta(minutes=31)
+    api.start_activity.side_effect = None
+    await service.tick()
+    assert api.start_activity.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_end_intent_survives_failure_restart_and_next_job(setup, monkeypatch):
+    service, api, factory, clock = await start(setup)
+    api.end_activity.side_effect = NotifyError("timeout", delivery_state="unknown")
+    service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
+    await service.tick()
+    assert (await rows(factory))[0].state == "ending"
+    await service.tick()
+    assert api.end_activity.await_count == 1
+    restarted = NotifyLiveActivityService(factory, api)
+    monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
+    restarted.observe(1, state(subtask_id="job2"))
+    clock[0] += timedelta(minutes=2)
+    api.end_activity.side_effect = None
+    await restarted.tick()
+    assert api.end_activity.call_args.args[2]["status"] == "Complete"
+    assert api.start_activity.await_count == 2
+    assert (await rows(factory))[0].state == "ended"
+
+
+@pytest.mark.asyncio
+async def test_late_complete_does_not_end_new_job(setup):
+    service, api, factory, clock = await start(setup)
+    service.observe(1, state(subtask_id="job2"))
+    await service.tick()
+    api.end_activity.reset_mock()
+    service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
+    await service.tick()
+    api.end_activity.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_fallback_survives_delta_and_late_job_id(setup):
+    service, api, factory, clock = setup
+    service.print_started(1, state(subtask_id=None, raw_data={"gcode_start_time": 12345}))
+    await service.tick()
+    clock[0] += timedelta(minutes=2)
+    service.observe(1, state(subtask_id=None, raw_data={}))
+    await service.tick()
+    service.observe(1, state(subtask_id="finally-known"))
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    assert api.end_activity.await_count == 0
+
+
+@pytest.mark.asyncio
+async def test_scope_and_disabled_provider_end_tiles(setup):
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.enabled = False
+        await db.commit()
+    await service.tick()
+    assert api.end_activity.await_count == 1
+    assert (await rows(factory))[0].state == "ended"
+
+
+@pytest.mark.asyncio
+async def test_delete_printer_retains_id_until_remote_end(setup):
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        await db.delete(await db.get(Printer, 1))
+        await db.commit()
+    await service.tick()
+    assert api.end_activity.await_count == 1
+    assert (await rows(factory))[0].state == "ended"
+
+
+@pytest.mark.asyncio
+async def test_cleanup_uses_old_token_and_removes_ownership(setup):
+    service, api, factory, clock = await start(setup)
+    await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
+    api.end_activity.assert_awaited_once_with("LA123456", "secret")
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_http_never_holds_database_session_and_concurrent_ticks_deduplicate(setup):
+    import asyncio
+
+    service, api, factory, clock = setup
+    entered = 0
+
+    class TrackedSession:
+        async def __aenter__(self):
+            nonlocal entered
+            entered += 1
+            self.db = factory()
+            return await self.db.__aenter__()
+
+        async def __aexit__(self, *args):
+            nonlocal entered
+            await self.db.__aexit__(*args)
+            entered -= 1
+
+    service._session = TrackedSession
+
+    async def send(*args):
+        assert entered == 0
+        await asyncio.sleep(0)
+        return {"activityId": "LA123456"}
+
+    api.start_activity.side_effect = send
+    service.observe(1, state())
+    await asyncio.gather(service.tick(), service.tick(), service.tick())
+    assert api.start_activity.await_count == 1
+
+
+def test_advanced_content_and_utf8_budget():
+    snapshot = PrintSnapshot.from_state(state(subtask_name="🔒" * 255, remaining_time=1500))
+    config = {
+        "live_activity_style": "segments",
+        "live_activity_metrics": ["progress", "eta", "layers", "nozzle", "bed", "chamber"],
+        "live_activity_button_url": "https://bambuddy.example/" + "a" * 480,
+        "live_activity_privacy": True,
+    }
+    content = snapshot.content("🖨" * 100, config)
+    assert "🔒" not in content["body"]
+    assert content["steps"] == 10 and content["step"] == 4
+    assert content["endsIn"] is None and content["trailing"] == "25h 0m"
+    assert len(content["metrics"]) == 5
+    assert len(json.dumps(content, ensure_ascii=False).encode()) < 2048
+    assert content["button"] is None or content["button"]["open"] is True
+    assert snapshot.content("P1", {"live_activity_style": "none"})["progress"] is None
+
+
+@pytest.mark.asyncio
+async def test_fallback_dismissal_follows_late_id_across_restart(setup, monkeypatch):
+    service, api, factory, clock = setup
+    service.print_started(1, state(subtask_id=None))
+    await service.tick()
+    clock[0] += timedelta(minutes=2)
+    api.get_activity.return_value = {"state": "dismissed", "endReason": "dismissed"}
+    await service.tick()
+    service.observe(1, state(subtask_id="late-id"))
+    await service.tick()
+    assert (await rows(factory))[0].job_key == "job:late-id"
+    restarted = NotifyLiveActivityService(factory, api)
+    monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
+    restarted.observe(1, state(subtask_id="late-id"))
+    await restarted.tick()
+    assert api.start_activity.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_end_retry_without_any_printer_snapshot(setup):
+    service, api, factory, clock = await start(setup)
+    api.end_activity.side_effect = NotifyError("unavailable", status_code=503)
+    service.print_finished(1, {"subtask_id": "job1", "status": "failed"})
+    await service.tick()
+    restarted = NotifyLiveActivityService(factory, api)
+    clock[0] += timedelta(minutes=2)
+    api.end_activity.side_effect = None
+    await restarted.tick()
+    assert api.end_activity.call_args.args[2]["status"] == "Failed"
+    assert (await rows(factory))[0].state == "ended"
+
+
+@pytest.mark.asyncio
+async def test_printer_scope_new_provider_separate_ownership(setup):
+    service, api, factory, clock = setup
+    async with factory() as db:
+        db.add(Printer(id=2, name="P2", serial_number="serial2", ip_address="127.0.0.2", access_code="12345678"))
+        db.add(
+            NotificationProvider(
+                id=2,
+                name="Other Phone",
+                provider_type="notify",
+                printer_id=2,
+                config=json.dumps({"device_id": "ABC12346", "token": "other", "live_activities": True}),
+            )
+        )
+        await db.commit()
+    await start(setup)
+    assert api.start_activity.await_count == 1
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 2)
+        provider.printer_id = 1
+        await db.commit()
+    await service.tick()
+    assert api.start_activity.await_count == 2
+    assert {r.provider_id for r in await rows(factory)} == {1, 2}
+
+
+@pytest.mark.asyncio
+async def test_unknown_start_record_after_process_crash_is_never_restarted(setup):
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        row = await db.get(NotificationLiveActivity, 1)
+        row.activity_id = None
+        row.state = "uncertain"
+        await db.commit()
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    assert (await rows(factory))[0].state == "suppressed"
+
+
+@pytest.mark.asyncio
+async def test_410_is_polled_for_reason_before_any_rollover(setup):
+    service, api, factory, clock = await start(setup)
+    api.update_activity.side_effect = NotifyError("gone", status_code=410)
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    api.get_activity.return_value = {"state": "dismissed", "endReason": "dismissed"}
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    assert (await rows(factory))[0].state == "suppressed"
+
+
+def fault(description="Nozzle temperature malfunction", severity=2, full_code="05004003", actions=None):
+    return SimpleNamespace(description=description, severity=severity, full_code=full_code, actions=actions)
+
+
+@pytest.mark.asyncio
+async def test_running_printer_fault_replaces_counter_and_restores_after_clear(setup):
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config = json.loads(provider.config)
+        config.update(live_activity_metrics=["progress", "eta"], live_activity_stage=True)
+        provider.config = json.dumps(config)
+        await db.commit()
+    service.observe(1, state(hms_errors=[fault()]))
+    await service.tick()
+    content = api.update_activity.call_args.args[2]
+    assert content["status"] == "Printer error"
+    assert content["body"] == "Nozzle temperature malfunction"
+    assert content["endsIn"] is None
+    assert content["metrics"] is None
+    service.observe(1, state(hms_errors=[]))
+    await service.tick()
+    content = api.update_activity.call_args.args[2]
+    assert content["status"] == "Printing"
+    assert content["endsIn"] == 900
+    assert len(content["metrics"]) == 2
+    assert api.start_activity.await_count == 1
+
+
+def test_runout_and_actionable_level_three_prompt():
+    snapshot = PrintSnapshot.from_state(state(hms_errors=[fault("Filament has run out", 3)]))
+    content = snapshot.content("P1", {"live_activity_privacy": True})
+    assert content["status"] == "Filament runout"
+    assert content["endsIn"] is None
+    assert "Test print" not in content["body"]
+    snapshot = PrintSnapshot.from_state(
+        state(hms_errors=[fault("Heatbreak fan problem", 3, "0000000012345678", ["retry"])])
+    )
+    assert snapshot.content("P1")["status"] == "Printer error"
+
+
+@pytest.mark.parametrize(
+    "error",
+    [
+        fault("The cover is open", 3, "0000000012345678"),
+        fault("Invalid level", 0),
+        fault("", 2),
+    ],
+)
+def test_advisory_does_not_interrupt_live_countdown(error):
+    content = PrintSnapshot.from_state(state(hms_errors=[error])).content("P1")
+    assert content["status"] == "Printing"
+    assert content["endsIn"] == 900
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("device_id", ["GRP12345", "WB123456", "MC123456"])
+async def test_non_ios_devices_never_start_activity(setup, device_id):
+    service, api, factory, clock = setup
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config = json.loads(provider.config)
+        config["device_id"] = device_id
+        provider.config = json.dumps(config)
+        await db.commit()
+    service.observe(1, state())
+    await service.tick()
+    api.start_activity.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_new_start_resets_previous_progress_and_eta_until_fresh_telemetry(setup):
+    service, api, factory, clock = setup
+    service.print_started(1, state(progress=99, remaining_time=1))
+    await service.tick()
+    assert api.start_activity.call_args.args[2]["progress"] == 0
+    assert api.start_activity.call_args.args[2]["endsIn"] is None
+    service.observe(1, state(progress=99, remaining_time=1))
+    clock[0] += timedelta(seconds=61)
+    await service.tick()
+    assert api.update_activity.call_args.args[2]["progress"] == 0
+    assert api.update_activity.call_args.args[2]["endsIn"] is None
+    service.observe(1, state(progress=2, remaining_time=50))
+    clock[0] += timedelta(seconds=61)
+    await service.tick()
+    assert api.update_activity.call_args.args[2]["progress"] == 2
+    assert api.update_activity.call_args.args[2]["endsIn"] == 3000
+
+
+@pytest.mark.asyncio
+async def test_old_tile_must_end_before_starting_next_job(setup):
+    service, api, factory, clock = await start(setup)
+    api.end_activity.side_effect = NotifyError("unavailable", status_code=503)
+    service.observe(1, state(subtask_id="job2"))
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    assert (await rows(factory))[0].state == "ending"
+    api.end_activity.side_effect = None
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert api.start_activity.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_complete_matches_learned_job_id_for_fallback_row(setup):
+    service, api, factory, clock = setup
+    service.print_started(1, state(subtask_id=None))
+    await service.tick()
+    service.observe(1, state(subtask_id="learned"))
+    await service.tick()
+    service.print_finished(1, {"raw_data": {"subtask_id": "learned"}, "status": "completed"})
+    await service.tick()
+    assert api.end_activity.call_args.args[2]["status"] == "Complete"
+    assert (await rows(factory))[0].state == "ended"
+
+
+def test_preparation_does_not_display_previous_print_progress():
+    snapshot = PrintSnapshot.from_state(state(layer_num=0, total_layers=100, progress=85))
+    content = snapshot.content("P1", {"live_activity_metrics": ["progress"]})
+    assert content["status"] == "Preparing"
+    assert content["progress"] == 0
+    assert content["metrics"][0]["value"] == "0%"
+
+
+@pytest.mark.asyncio
+async def test_reenable_resumes_current_print_after_successful_cleanup(setup):
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.enabled = False
+        await db.commit()
+    await service.tick()
+    assert (await rows(factory))[0].end_reason == "disabled"
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.enabled = True
+        await db.commit()
+    await service.tick()
+    assert api.start_activity.await_count == 1  # Wait for fresh telemetry after dormant time.
+    service.observe(1, state())
+    await service.tick()
+    assert api.start_activity.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_reenable_waits_for_failed_cleanup_before_restarting(setup):
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.enabled = False
+        await db.commit()
+    api.end_activity.side_effect = NotifyError("unavailable", status_code=503)
+    await service.tick()
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.enabled = True
+        await db.commit()
+    await service.tick()
+    assert api.start_activity.await_count == 1
+    api.end_activity.side_effect = None
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    service.observe(1, state())
+    await service.tick()
+    assert api.start_activity.await_count == 2
+
+
+@pytest.mark.parametrize("config", [{}, {"live_activity_metrics": []}])
+def test_default_tile_uses_native_timer_without_metric_chips(config):
+    content = PrintSnapshot.from_state(state()).content("P1", config)
+    assert content["endsIn"] == 900
+    assert content["metrics"] is None
+    assert content["trailing"] is None
+
+
+@pytest.mark.asyncio
+async def test_disabling_optional_metrics_clears_chips_and_preserves_timer(setup):
+    service, api, factory, clock = setup
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config = json.loads(provider.config)
+        config["live_activity_metrics"] = ["progress", "eta"]
+        provider.config = json.dumps(config)
+        await db.commit()
+    await start(setup)
+    content = api.start_activity.call_args.args[2]
+    assert content["endsIn"] == 900
+    assert content["metrics"][0] == {"label": "Progress", "value": "42%", "unit": ""}
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config["live_activity_metrics"] = []
+        provider.config = json.dumps(config)
+        await db.commit()
+    clock[0] += timedelta(minutes=1)
+    await service.tick()
+    content = api.update_activity.call_args.args[2]
+    assert content["metrics"] is None
+    assert content["endsIn"] == 840
+    assert api.start_activity.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_dormant_worker_has_no_periodic_sql_or_mqtt_wakeups(setup):
+    import asyncio
+
+    from sqlalchemy import event
+
+    service, api, factory, clock = setup
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.enabled = False
+        await db.commit()
+    queries = []
+    engine = factory.kw["bind"]
+
+    def record(connection, cursor, statement, parameters, context, many):
+        queries.append(statement)
+
+    event.listen(engine.sync_engine, "before_cursor_execute", record)
+    service.worker_interval = 0.01
+    service.start()
+    try:
+        for _ in range(100):
+            if not service._initial_discovery:
+                break
+            await asyncio.sleep(0.001)
+        queries.clear()
+        service.observe(1, state())
+        service.print_started(1, state())
+        service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
+        await asyncio.sleep(0.05)
+        assert queries == []
+        assert service._snapshots == {}
+        api.start_activity.assert_not_awaited()
+    finally:
+        await service.close()
+        event.remove(engine.sync_engine, "before_cursor_execute", record)
+
+
+@pytest.mark.asyncio
+async def test_startup_waits_for_real_status_before_touching_existing_activity(setup):
+    service, api, factory, clock = await start(setup)
+    clock[0] += timedelta(minutes=2)
+    restarted = NotifyLiveActivityService(factory, api)
+    await restarted.tick()
+    api.get_activity.assert_not_awaited()
+    restarted.observe(1, state(state="unknown", progress=0))
+    await restarted.tick()
+    api.get_activity.assert_not_awaited()
+    restarted.observe(1, state())
+    await restarted.tick()
+    api.get_activity.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("connected", [None, False, True])
+async def test_startup_grace_expires_without_real_status_and_reconnect_resumes(setup, connected):
+    service, api, factory, clock = await start(setup)
+    clock[0] += timedelta(minutes=2)
+    restarted = NotifyLiveActivityService(factory, api)
+    # Importing the service before worker startup must not consume the grace.
+    clock[0] += timedelta(minutes=5)
+
+    # Neither complete silence nor broker-only status may extend the grace.
+    for seconds in (0, 90, 29):
+        clock[0] += timedelta(seconds=seconds)
+        if connected is not None:
+            restarted.observe(1, state(state="unknown", connected=connected, progress=0))
+        await restarted.tick()
+        api.get_activity.assert_not_awaited()
+        api.update_activity.assert_not_awaited()
+
+    clock[0] += timedelta(seconds=1)
+    await restarted.tick()
+    api.get_activity.assert_awaited_once_with("LA123456", "secret")
+    content = api.update_activity.call_args.args[2]
+    assert content["status"] == "Printer offline"
+    assert content["endsIn"] is None
+    assert content["progress"] == 42
+    saved = (await rows(factory))[0]
+    assert saved.eta_seconds is None
+    assert saved.eta_deadline is None
+    assert saved.activity_id == "LA123456"
+    api.end_activity.assert_not_awaited()
+    assert api.start_activity.await_count == 1
+
+    # A real status resumes the same tile immediately, even during throttling.
+    restarted.observe(1, state(progress=46, remaining_time=10))
+    await restarted.tick()
+    assert api.update_activity.await_count == 2
+    assert api.update_activity.call_args.args[0] == "LA123456"
+    content = api.update_activity.call_args.args[2]
+    assert content["status"] == "Printing"
+    assert content["endsIn"] == 600
+    assert content["progress"] == 46
+    assert api.start_activity.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("reason", ["expired", "dismissed"])
+async def test_startup_grace_never_restarts_an_offline_printers_activity(setup, reason):
+    service, api, factory, clock = await start(setup)
+    restarted = NotifyLiveActivityService(factory, api)
+    await restarted.tick()
+    api.get_activity.return_value = {"state": "ended", "endReason": reason}
+    clock[0] += timedelta(minutes=2)
+    await restarted.tick()
+    api.get_activity.assert_awaited_once_with("LA123456", "secret")
+    api.update_activity.assert_not_awaited()
+    api.end_activity.assert_not_awaited()
+    assert api.start_activity.await_count == 1
+    assert (await rows(factory))[0].state == ("pending" if reason == "expired" else "suppressed")
+
+
+@pytest.mark.asyncio
+async def test_startup_offline_clears_saved_eta_labels(setup):
+    service, api, factory, clock = setup
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config = json.loads(provider.config)
+        config["live_activity_metrics"] = ["progress", "eta", "nozzle"]
+        provider.config = json.dumps(config)
+        await db.commit()
+    service.observe(1, state(remaining_time=25 * 60))
+    await service.tick()
+    original = api.start_activity.call_args.args[2]
+    assert original["trailing"] == "25h 0m"
+    assert any(metric["label"] == "Remaining" for metric in original["metrics"])
+
+    restarted = NotifyLiveActivityService(factory, api)
+    await restarted.tick()
+    clock[0] += timedelta(minutes=2)
+    await restarted.tick()
+    content = api.update_activity.call_args.args[2]
+    assert content["status"] == "Printer offline"
+    assert content["endsIn"] is None
+    assert content["trailing"] is None
+    assert content["metrics"] is None
+    assert content["progress"] == 42
+
+
+@pytest.mark.asyncio
+async def test_scheduled_cleanup_does_not_wait_for_create_and_keeps_late_handle(setup):
+    import asyncio
+
+    service, api, factory, clock = setup
+    entered, release = asyncio.Event(), asyncio.Event()
+
+    async def create(*args):
+        entered.set()
+        await release.wait()
+        return {"activityId": "LA123456"}
+
+    api.start_activity.side_effect = create
+    service.observe(1, state())
+    tick = asyncio.create_task(service.tick())
+    await entered.wait()
+    await asyncio.wait_for(service.schedule_cleanup(1, {"device_id": "ABC12345", "token": "secret"}), 0.2)
+    async with factory() as db:
+        await db.delete(await db.get(NotificationProvider, 1))
+        await db.commit()
+    release.set()
+    await tick
+    try:
+        for _ in range(100):
+            if api.end_activity.await_count:
+                break
+            await asyncio.sleep(0.005)
+        api.end_activity.assert_awaited_once_with("LA123456", "secret")
+        assert await rows(factory) == []
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+async def test_boot_cleans_disabled_activity_without_printer_queries_or_pruning(setup):
+    import asyncio
+
+    from sqlalchemy import event
+
+    service, api, factory, clock = await start(setup)
+    async with factory() as db:
+        (await db.get(NotificationProvider, 1)).enabled = False
+        await db.commit()
+    statements = []
+    engine = factory.kw["bind"]
+
+    def record(connection, cursor, statement, parameters, context, many):
+        statements.append(statement.lower())
+
+    event.listen(engine.sync_engine, "before_cursor_execute", record)
+    restarted = NotifyLiveActivityService(factory, api)
+    restarted.start()
+    try:
+        for _ in range(100):
+            if (await rows(factory))[0].state == "ended":
+                break
+            await asyncio.sleep(0.005)
+        assert (await rows(factory))[0].state == "ended"
+        assert not any("from printers" in statement for statement in statements)
+        assert not any(statement.startswith("delete from notification_live_activities") for statement in statements)
+    finally:
+        await restarted.close()
+        event.remove(engine.sync_engine, "before_cursor_execute", record)

+ 203 - 0
backend/tests/unit/services/test_notify_widget_client.py

@@ -0,0 +1,203 @@
+"""Wire contracts for persistent Notify Lock Screen widgets."""
+
+import json
+from unittest.mock import AsyncMock
+
+import httpx
+import pytest
+
+from backend.app.services.notify_client import NotifyClient, NotifyError, notify_credentials
+
+
+async def test_widgets_create_explicitly_then_use_the_exact_saved_handle():
+    requests = []
+    widget = {
+        "widgetId": "WG123456",
+        "content": {"title": "X1C", "value": "62", "unit": "%", "progress": 62},
+        "createdAt": "2026-10-07T00:00:00Z",
+        "updatedAt": "2026-10-07T00:00:00Z",
+    }
+
+    def handle(request):
+        requests.append(request)
+        if request.method == "DELETE":
+            return httpx.Response(200, json={"success": True, "deleted": True, "widgetId": "WG123456"})
+        if request.method == "GET" and request.url.path.endswith("ABC12345"):
+            return httpx.Response(200, json={"widgets": [widget]})
+        return httpx.Response(201 if "new" in request.url.params else 200, json=widget)
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        client = NotifyClient(http)
+        assert await client.create_widget("ABC12345", "a+b&c", widget["content"]) == widget
+        assert await client.update_widget("WG123456", "a+b&c", {"value": "Idle", "progress": None}) == widget
+        assert await client.get_widget("WG123456", "a+b&c") == widget
+        assert await client.list_widgets("ABC12345", "a+b&c") == {"widgets": [widget]}
+        assert (await client.delete_widget("WG123456", "a+b&c"))["deleted"] is True
+
+    assert requests[0].url.path == "/widgets/ABC12345"
+    assert requests[0].url.params["new"] == "1"
+    assert requests[0].headers["content-type"] == "application/json"
+    assert json.loads(requests[0].content) == widget["content"]
+    assert requests[1].url.path == requests[2].url.path == requests[4].url.path == "/widgets/WG123456"
+    assert json.loads(requests[1].content) == {"value": "Idle", "progress": None}
+    for request in requests[1:]:
+        assert dict(request.url.params) == {"token": "a+b&c"}
+    assert [r.method for r in requests] == ["POST", "POST", "GET", "GET", "DELETE"]
+    assert not requests[2].content and not requests[3].content and not requests[4].content
+
+
+async def test_pasted_credentials_are_normalized_for_activity_and_widget_requests():
+    requests = []
+
+    def handle(request):
+        requests.append(request)
+        key, handle = (
+            ("widgetId", "WG123456") if request.url.path.startswith("/widgets/") else ("activityId", "LA123456")
+        )
+        return httpx.Response(200, json={key: handle})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        client = NotifyClient(http)
+        await client.start_activity("  IO12345678901234\n", "  a+b&c\n", {"title": "X1C"})
+        await client.update_activity("LA123456", "  a+b&c\n", {"progress": 62})
+        await client.create_widget("  IO12345678901234\n", "  a+b&c\n", {"title": "X1C"})
+        await client.update_widget("WG123456", "  a+b&c\n", {"progress": 62})
+    assert requests[0].url.path == "/live-activity/IO12345678901234"
+    assert requests[2].url.path == "/widgets/IO12345678901234"
+    assert all(request.url.params["token"] == "a+b&c" for request in requests)
+
+
+@pytest.mark.parametrize(("status", "body"), [(404, {}), (200, {"type": "group"}), (200, {"success": True})])
+async def test_ambiguous_la_id_cannot_update_an_unowned_activity(status, body):
+    requests = []
+
+    def handle(request):
+        requests.append(request)
+        return httpx.Response(status, json=body)
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        with pytest.raises(NotifyError):
+            await NotifyClient(http).start_activity(" LA123456 ", "secret", {"title": "X1C"})
+    assert len(requests) == 1
+    assert requests[0].method == "GET"
+    assert requests[0].url.path == "/link"
+    assert requests[0].url.params["id"] == "LA123456"
+
+
+async def test_legacy_la_prefixed_device_can_start_after_identity_verification():
+    requests = []
+
+    def handle(request):
+        requests.append(request)
+        if request.url.path == "/link":
+            return httpx.Response(200, json={"type": "device", "id": "LA123456"})
+        return httpx.Response(200, json={"activityId": "LA654321"})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        result = await NotifyClient(http).start_activity("LA123456", "secret", {"title": "X1C"})
+    assert result["activityId"] == "LA654321"
+    assert [r.method for r in requests] == ["GET", "POST"]
+    assert requests[1].url.path == "/live-activity/LA123456"
+    assert requests[1].url.params["new"] == "1"
+
+
+@pytest.mark.parametrize("method", ["update_widget", "get_widget", "delete_widget"])
+@pytest.mark.parametrize("widget_id", ["ABC12345", "WGbad123", "WG12345", "WG1234567", "../WG123456", None])
+async def test_widget_operations_reject_device_dialect_and_invalid_handles(method, widget_id):
+    http = AsyncMock(spec=httpx.AsyncClient)
+    args = (widget_id, "secret", {}) if method == "update_widget" else (widget_id, "secret")
+    with pytest.raises(NotifyError, match="precise widget ID"):
+        await getattr(NotifyClient(http), method)(*args)
+    http.request.assert_not_called()
+
+
+@pytest.mark.parametrize("body", [{}, [], {"success": True}, {"widgetId": "ABC12345"}, {"widgetId": "bad/id"}])
+async def test_create_without_a_precise_handle_is_uncertain(body):
+    async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(201, json=body))) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).create_widget("ABC12345", "secret", {"title": "X1C"})
+    assert error.value.delivery_state == "unknown"
+
+
+@pytest.mark.parametrize(
+    "body",
+    [{"success": True}, {"widgets": None}, {"widgets": {}}, {"widgets": [None]}, {"widgets": [{"widgetId": "bad"}]}],
+)
+async def test_malformed_widget_list_cannot_prove_that_owned_widget_was_deleted(body):
+    async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: httpx.Response(200, json=body))) as http:
+        with pytest.raises(NotifyError, match="invalid widget list"):
+            await NotifyClient(http).list_widgets("ABC12345", "secret")
+
+
+async def test_empty_authenticated_widget_list_is_valid():
+    async with httpx.AsyncClient(
+        transport=httpx.MockTransport(lambda _: httpx.Response(200, json={"widgets": []}))
+    ) as http:
+        assert await NotifyClient(http).list_widgets("ABC12345", "secret") == {"widgets": []}
+
+
+@pytest.mark.parametrize(
+    ("status", "body", "delivery", "widget_id", "retry"),
+    [
+        (500, {}, "unknown", None, None),
+        (502, {"widgetId": "WG123456"}, "unknown", "WG123456", None),
+        (503, {}, None, None, None),
+        (429, {"retryAfterSeconds": 180}, None, None, 180),
+        (502, {"deliveryState": "not-delivered", "retryAfterSeconds": 60}, "not-delivered", None, 60),
+        (400, {"message": "This device already has the maximum of 10 widgets. Delete one first."}, None, None, None),
+        (403, {}, None, None, None),
+    ],
+)
+async def test_create_preserves_recovery_hints_without_retrying(status, body, delivery, widget_id, retry):
+    requests = []
+
+    def handle(request):
+        requests.append(request)
+        return httpx.Response(status, json={**body, "private": "secret"})
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).create_widget("ABC12345", "secret", {"title": "X1C"})
+    assert len(requests) == 1
+    assert error.value.status_code == status
+    assert error.value.delivery_state == delivery
+    assert error.value.widget_id == widget_id
+    assert error.value.retry_after_seconds == retry
+    assert "secret" not in str(error.value)
+    assert "Live Activity" not in str(error.value)
+    if status == 400:
+        assert "ten widget limit" in str(error.value)
+
+
+async def test_widget_create_timeout_is_uncertain_and_does_not_expose_token():
+    def handle(request):
+        raise httpx.ReadTimeout(f"Timed out: {request.url}", request=request)
+
+    async with httpx.AsyncClient(transport=httpx.MockTransport(handle)) as http:
+        with pytest.raises(NotifyError) as error:
+            await NotifyClient(http).create_widget("ABC12345", "secret", {"title": "X1C"})
+    assert error.value.delivery_state == "unknown"
+    assert "secret" not in str(error.value)
+    assert error.value.__suppress_context__
+
+
+@pytest.mark.parametrize("device_id", ["GRP12345", "WB12345678901234", "MC12345678901234"])
+def test_bambuddy_widgets_require_ios_while_push_remains_available(device_id):
+    config = {"device_id": device_id, "token": "secret", "lock_screen_widgets": True}
+    with pytest.raises(NotifyError, match="Lock Screen widgets require an iOS device ID"):
+        notify_credentials(config)
+    config["lock_screen_widgets"] = False
+    assert notify_credentials(config) == (device_id, "secret")
+
+
+@pytest.mark.parametrize("value", ["true", "false", 1, 0, None, []])
+def test_widget_switch_requires_a_boolean(value):
+    with pytest.raises(NotifyError, match="lock_screen_widgets must be a boolean"):
+        notify_credentials({"device_id": "ABC12345", "token": "secret", "lock_screen_widgets": value})
+
+
+@pytest.mark.parametrize("device_id", ["ABC12345", "IO12345678901234"])
+def test_widgets_can_be_enabled_independently_of_live_activities(device_id):
+    assert notify_credentials(
+        {"device_id": device_id, "token": "secret", "lock_screen_widgets": True, "live_activities": False}
+    ) == (device_id, "secret")

+ 790 - 0
backend/tests/unit/services/test_notify_widgets.py

@@ -0,0 +1,790 @@
+"""Persisted Notify widget ownership and write cadence against a fake gateway."""
+
+import asyncio
+import json
+from datetime import datetime, timedelta
+from types import SimpleNamespace
+from unittest.mock import AsyncMock
+
+import pytest
+from sqlalchemy import delete, event, select, text, update
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+
+import backend.app.models  # noqa: F401
+from backend.app.core.database import Base
+from backend.app.models.notification import NotificationProvider
+from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
+from backend.app.models.printer import Printer
+from backend.app.services import notify_widgets as module
+from backend.app.services.notify_client import NotifyError
+from backend.app.services.notify_widgets import NotifyWidgetService, widget_content
+
+
+def state(**values):
+    defaults = {
+        "connected": True,
+        "state": "RUNNING",
+        "subtask_id": "job1",
+        "subtask_name": "job.3mf",
+        "current_print": "job.3mf",
+        "progress": 42,
+        "remaining_time": 15,
+        "layer_num": 42,
+        "total_layers": 100,
+        "hms_errors": [],
+        "raw_data": {},
+        "temperatures": {},
+        "stg_cur": -1,
+    }
+    return SimpleNamespace(**{**defaults, **values})
+
+
+@pytest.fixture
+async def setup(tmp_path, monkeypatch):
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'widgets.db'}")
+
+    @event.listens_for(engine.sync_engine, "connect")
+    def enable_fk(connection, _):
+        cursor = connection.cursor()
+        cursor.execute("PRAGMA foreign_keys=ON")
+        cursor.close()
+
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    factory = async_sessionmaker(engine, expire_on_commit=False)
+    async with factory() as db:
+        db.add(Printer(id=1, name="Printer 1", serial_number="serial1", ip_address="127.0.0.1", access_code="12345678"))
+        db.add(
+            NotificationProvider(
+                id=1,
+                name="Phone",
+                provider_type="notify",
+                enabled=True,
+                config=json.dumps({"device_id": "ABC12345", "token": "secret", "lock_screen_widgets": True}),
+                on_print_start=False,
+                on_print_complete=False,
+                daily_digest_enabled=True,
+                quiet_hours_enabled=True,
+                quiet_hours_start="00:00",
+                quiet_hours_end="23:59",
+            )
+        )
+        await db.commit()
+    api = SimpleNamespace(
+        create_widget=AsyncMock(return_value={"widgetId": "WG123456"}),
+        update_widget=AsyncMock(return_value={"widgetId": "WG123456"}),
+        delete_widget=AsyncMock(return_value={"success": True}),
+        list_widgets=AsyncMock(return_value={"widgets": []}),
+    )
+    clock = [datetime(2026, 1, 1, 12)]
+    monkeypatch.setattr(module, "_now", lambda: clock[0])
+    states = {1: state()}
+    service = NotifyWidgetService(factory, api, states.get)
+    yield service, api, factory, clock, states
+    await engine.dispose()
+
+
+async def rows(factory):
+    async with factory() as db:
+        return (await db.scalars(select(NotificationLockScreenWidget))).all()
+
+
+async def configure(factory, **values):
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        config = json.loads(provider.config)
+        config.update(values)
+        provider.config = json.dumps(config)
+        await db.commit()
+
+
+@pytest.mark.asyncio
+async def test_default_opt_out_and_independent_of_push_digest_quiet_hours(setup):
+    service, api, factory, clock, states = setup
+    await configure(factory, lock_screen_widgets=False)
+    await service.tick()
+    api.create_widget.assert_not_awaited()
+    await configure(factory, lock_screen_widgets=True)
+    await service.tick()
+    api.create_widget.assert_awaited_once()
+    assert (await rows(factory))[0].widget_id == "WG123456"
+
+
+@pytest.mark.asyncio
+async def test_restart_reuses_exact_widget_across_jobs_and_completion(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    restarted = NotifyWidgetService(factory, api, states.get)
+    states[1] = state(state="FINISH", progress=100)
+    clock[0] += timedelta(minutes=1)
+    await restarted.tick()
+    assert api.update_widget.call_args.args[:2] == ("WG123456", "secret")
+    assert api.update_widget.call_args.args[2]["value"] == "Complete"
+    states[1] = state(subtask_id="job2", progress=2)
+    clock[0] += timedelta(minutes=1)
+    await restarted.tick()
+    assert api.create_widget.await_count == 1
+    assert api.update_widget.call_args.args[2]["value"] == "2"
+    api.delete_widget.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_only_changed_values_send_and_at_most_once_per_minute(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    states[1] = state(progress=43)
+    clock[0] += timedelta(seconds=59)
+    await service.tick()
+    api.update_widget.assert_not_awaited()
+    clock[0] += timedelta(seconds=1)
+    await service.tick()
+    assert api.update_widget.await_count == 1
+    clock[0] += timedelta(minutes=60)
+    await service.tick()
+    assert api.update_widget.await_count == 1
+    api.list_widgets.assert_awaited_once_with("ABC12345", "secret")
+
+
+@pytest.mark.asyncio
+async def test_offline_and_idle_widgets_are_persistent(setup):
+    service, api, factory, clock, states = setup
+    states.clear()
+    await service.tick()
+    assert api.create_widget.call_args.args[2]["value"] == "Offline"
+    states[1] = state(state="IDLE")
+    clock[0] += timedelta(minutes=1)
+    await service.tick()
+    assert api.update_widget.call_args.args[2]["value"] == "Idle"
+    assert api.create_widget.await_count == 1
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("device_id", ["GRP12345", "WB123456", "MC123456"])
+async def test_unsupported_devices_are_push_only(setup, device_id):
+    service, api, factory, clock, states = setup
+    await configure(factory, device_id=device_id)
+    await service.tick()
+    api.create_widget.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_ambiguous_create_is_not_repeated_and_has_manual_recovery_message(setup):
+    service, api, factory, clock, states = setup
+    api.create_widget.side_effect = NotifyError("Timeout", delivery_state="unknown")
+    await service.tick()
+    clock[0] += timedelta(hours=2)
+    restarted = NotifyWidgetService(factory, api, states.get)
+    await restarted.tick()
+    assert api.create_widget.await_count == 1
+    assert (await rows(factory))[0].state == "uncertain"
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        assert "turn Lock Screen widgets off and on" in provider.last_error
+    # Explicit opt-out clears unknown ownership after the user can inspect Notify.
+    await configure(factory, lock_screen_widgets=False)
+    await restarted.tick()
+    assert await rows(factory) == []
+    await configure(factory, lock_screen_widgets=True)
+    api.create_widget.side_effect = None
+    await restarted.tick()
+    assert api.create_widget.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_process_crash_after_create_intent_does_not_duplicate(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    async with factory() as db:
+        row = await db.get(NotificationLockScreenWidget, 1)
+        row.state, row.widget_id = "uncertain", None
+        await db.commit()
+    restarted = NotifyWidgetService(factory, api, states.get)
+    await restarted.tick()
+    assert api.create_widget.await_count == 1
+    async with factory() as db:
+        assert "Previous creation was interrupted" in (await db.get(NotificationProvider, 1)).last_error
+
+
+@pytest.mark.asyncio
+async def test_ambiguous_create_with_id_recovers_exact_id(setup):
+    service, api, factory, clock, states = setup
+    api.create_widget.side_effect = NotifyError(
+        "unknown", status_code=502, payload={"widgetId": "WG123456"}, delivery_state="unknown"
+    )
+    await service.tick()
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    assert api.create_widget.await_count == 1
+    assert api.update_widget.call_args.args[0] == "WG123456"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+    "error",
+    [
+        NotifyError("disabled", status_code=503),
+        NotifyError("throttled", status_code=429, retry_after_seconds=1800),
+    ],
+)
+async def test_retryable_creates_back_off(setup, error):
+    service, api, factory, clock, states = setup
+    api.create_widget.side_effect = error
+    await service.tick()
+    await service.tick()
+    assert api.create_widget.await_count == 1
+    clock[0] += timedelta(minutes=31)
+    api.create_widget.side_effect = None
+    await service.tick()
+    assert api.create_widget.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_delete_retry_survives_restart_and_reenable(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await configure(factory, lock_screen_widgets=False)
+    api.delete_widget.side_effect = NotifyError("timeout", delivery_state="unknown")
+    await service.tick()
+    assert (await rows(factory))[0].state == "deleting"
+    restarted = NotifyWidgetService(factory, api, states.get)
+    await configure(factory, lock_screen_widgets=True)
+    await restarted.tick()
+    assert api.create_widget.await_count == 1
+    assert api.delete_widget.await_count == 1
+    clock[0] += timedelta(minutes=2)
+    api.delete_widget.side_effect = None
+    await restarted.tick()
+    await restarted.tick()
+    assert api.create_widget.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_deleted_printer_widget_is_deleted_with_fk_enforced(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    async with factory() as db:
+        await db.delete(await db.get(Printer, 1))
+        await db.commit()
+    await service.tick()
+    api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_cleanup_addresses_exact_saved_widget_with_old_token(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
+    api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_deleted_remote_widget_403_is_confirmed_by_device_list(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await configure(factory, lock_screen_widgets=False)
+    api.delete_widget.side_effect = NotifyError("Forbidden", status_code=403)
+    await service.tick()
+    assert api.list_widgets.await_count == 2
+    assert api.list_widgets.call_args.args == ("ABC12345", "secret")
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_bad_credentials_do_not_discard_widget_needing_cleanup(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await configure(factory, lock_screen_widgets=False)
+    api.delete_widget.side_effect = NotifyError("Forbidden", status_code=403)
+    api.list_widgets.side_effect = NotifyError("Forbidden", status_code=403)
+    await service.tick()
+    assert (await rows(factory))[0].state == "deleting"
+
+
+@pytest.mark.asyncio
+async def test_success_does_not_clear_push_error(setup):
+    service, api, factory, clock, states = setup
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.last_error = "Push failed"
+        await db.commit()
+    await service.tick()
+    async with factory() as db:
+        assert (await db.get(NotificationProvider, 1)).last_error == "Push failed"
+
+
+@pytest.mark.asyncio
+async def test_no_database_session_during_http_and_concurrent_ticks_deduplicate(setup):
+    service, api, factory, clock, states = setup
+    entered = 0
+
+    class TrackedSession:
+        async def __aenter__(self):
+            nonlocal entered
+            entered += 1
+            self.db = factory()
+            return await self.db.__aenter__()
+
+        async def __aexit__(self, *args):
+            nonlocal entered
+            await self.db.__aexit__(*args)
+            entered -= 1
+
+    service._session = TrackedSession
+
+    async def send(*args):
+        assert entered == 0
+        await asyncio.sleep(0)
+        return {"widgetId": "WG123456"}
+
+    api.create_widget.side_effect = send
+    await asyncio.gather(service.tick(), service.tick())
+    assert api.create_widget.await_count == 1
+
+
+def test_faults_replace_gauge_and_advisories_do_not():
+    fault = SimpleNamespace(description="Filament has run out", severity=2, full_code="07004000", actions=None)
+    content = widget_content("P1", state(hms_errors=[fault]))
+    assert content["value"] == "Error" and content["detail"] == "Filament has run out"
+    assert content["unit"] is None and content["progress"] is None
+    advisory = SimpleNamespace(description="Cover is open", severity=3, full_code="0000000012345678", actions=None)
+    content = widget_content("P1", state(hms_errors=[advisory]))
+    assert content["value"] == "42" and content["progress"] == 42
+    assert "endsIn" not in content and "metrics" not in content
+
+
+def test_payload_caps_and_static_eta():
+    content = widget_content("🖨" * 120, state(remaining_time=1500))
+    assert "25h 0m" in content["detail"]
+    assert len(json.dumps(content, ensure_ascii=False, separators=(",", ":")).encode()) <= 1024
+    content = widget_content("P1", state(state="PAUSE"))
+    assert content["value"] == "Paused" and content["progress"] is None
+
+
+@pytest.mark.asyncio
+async def test_opt_out_during_create_cannot_be_overwritten_by_response(setup):
+    service, api, factory, clock, states = setup
+
+    async def create(*args):
+        async with factory() as db:
+            await db.execute(
+                update(NotificationLockScreenWidget).values(state="deleting", failures=0, next_attempt_at=None)
+            )
+            await db.commit()
+        return {"widgetId": "WG123456"}
+
+    api.create_widget.side_effect = create
+    await service.tick()
+    row = (await rows(factory))[0]
+    assert row.state == "deleting" and row.widget_id == "WG123456"
+    await service.tick()
+    api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_uncertain_intent_reset_does_not_wait_for_backoff(setup):
+    service, api, factory, clock, states = setup
+    api.create_widget.side_effect = NotifyError("Timeout", delivery_state="unknown")
+    await service.tick()
+    await configure(factory, lock_screen_widgets=False)
+    await service.tick()
+    assert await rows(factory) == []
+
+
+@pytest.mark.asyncio
+async def test_token_rotation_preserves_widget_and_resumes_credential_failure(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    async with factory() as db:
+        row = await db.get(NotificationLockScreenWidget, 1)
+        row.state = "suppressed"
+        await db.commit()
+    await configure(factory, token="rotated")
+    await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
+    row = (await rows(factory))[0]
+    assert row.widget_id == "WG123456" and row.state == "active"
+    await service.tick()
+    api.update_widget.assert_awaited_once()
+    assert api.update_widget.call_args.args[:2] == ("WG123456", "rotated")
+    api.delete_widget.assert_not_awaited()
+    assert api.create_widget.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_token_rotation_does_not_retry_unknown_creation(setup):
+    service, api, factory, clock, states = setup
+    api.create_widget.side_effect = NotifyError("unknown", delivery_state="unknown")
+    await service.tick()
+    await configure(factory, token="rotated")
+    await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
+    await service.tick()
+    assert api.create_widget.await_count == 1
+    assert (await rows(factory))[0].state == "uncertain"
+
+
+@pytest.mark.asyncio
+async def test_device_change_cleanup_failure_remains_visible_after_new_device_success(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await configure(factory, device_id="ABC12346", token="other")
+    api.delete_widget.side_effect = NotifyError("timeout", delivery_state="unknown")
+    await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
+    assert await rows(factory) == []
+    await service.tick()
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        assert "Remove the previous device" in provider.last_error
+    assert api.create_widget.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_printer_scope_exclusion_deletes_only_owned_widget(setup):
+    service, api, factory, clock, states = setup
+    async with factory() as db:
+        db.add(Printer(id=2, name="Printer 2", serial_number="serial2", ip_address="127.0.0.2", access_code="12345678"))
+        await db.commit()
+    api.create_widget.side_effect = [{"widgetId": "WG123456"}, {"widgetId": "WG654321"}]
+    await service.tick()
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        provider.printer_id = 2
+        await db.commit()
+    await service.tick()
+    api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+    assert [(row.printer_id, row.widget_id) for row in await rows(factory)] == [(2, "WG654321")]
+
+
+@pytest.mark.asyncio
+async def test_read_preflight_never_creates_or_adopts_unrelated_widget(setup):
+    service, api, factory, clock, states = setup
+    api.list_widgets.side_effect = NotifyError(
+        "Expected a device widget list", delivery_state="unknown", payload={"widgetId": "WG654321"}
+    )
+    await service.tick()
+    api.create_widget.assert_not_awaited()
+    row = (await rows(factory))[0]
+    assert row.widget_id is None and row.state == "pending"
+    clock[0] += timedelta(minutes=2)
+    api.list_widgets.side_effect = None
+    await service.tick()
+    api.create_widget.assert_awaited_once()
+
+
+@pytest.mark.asyncio
+async def test_credential_whitespace_does_not_create_duplicate_ownership(setup):
+    service, api, factory, clock, states = setup
+    await configure(factory, device_id=" ABC12345 ", token=" secret ")
+    await service.tick()
+    await configure(factory, device_id="ABC12345", token="rotated")
+    await service.cleanup_provider(1, {"device_id": " ABC12345 ", "token": " secret "})
+    await service.tick()
+    assert api.create_widget.await_count == 1
+    api.delete_widget.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_dormant_widgets_stop_all_periodic_database_work(setup):
+    service, api, factory, clock, states = setup
+    await configure(factory, lock_screen_widgets=False)
+    queries = []
+    engine = factory.kw["bind"]
+
+    def record(connection, cursor, statement, parameters, context, many):
+        queries.append(statement)
+
+    event.listen(engine.sync_engine, "before_cursor_execute", record)
+    service.worker_interval = 0.01
+    service.start()
+    try:
+        for _ in range(100):
+            if not service._initial_discovery:
+                break
+            await asyncio.sleep(0.001)
+        queries.clear()
+        await asyncio.sleep(0.05)
+        assert queries == []
+        api.create_widget.assert_not_awaited()
+    finally:
+        await service.close()
+        event.remove(engine.sync_engine, "before_cursor_execute", record)
+
+
+@pytest.mark.asyncio
+async def test_restart_drops_rows_of_deleted_provider_before_id_is_reused(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    assert len(await rows(factory)) == 1
+    # Production SQLite does not enforce the cascade, and the in-memory cleanup
+    # job is lost when Bambuddy restarts before it drains.
+    async with factory() as db:
+        await db.execute(text("PRAGMA foreign_keys=OFF"))
+        await db.execute(delete(NotificationProvider).where(NotificationProvider.id == 1))
+        await db.commit()
+        await db.execute(text("PRAGMA foreign_keys=ON"))
+    assert len(await rows(factory)) == 1
+
+    restarted = NotifyWidgetService(factory, api, states.get)
+    restarted.start()
+    try:
+        for _ in range(100):
+            if not restarted._initial_discovery:
+                break
+            await asyncio.sleep(0.001)
+    finally:
+        await restarted.close()
+    assert await rows(factory) == []
+
+    # The next provider reuses the freed id and starts from a clean slate.
+    async with factory() as db:
+        db.add(
+            NotificationProvider(
+                id=1,
+                name="New phone",
+                provider_type="notify",
+                enabled=True,
+                config=json.dumps({"device_id": "XYZ98765", "token": "other", "lock_screen_widgets": True}),
+            )
+        )
+        await db.commit()
+
+    api.create_widget.reset_mock()
+    await restarted.tick()
+    api.create_widget.assert_awaited_once()
+    assert [row.credential_key for row in await rows(factory)] == [
+        module._credential_key({"device_id": "XYZ98765", "token": "other"})
+    ]
+
+
+@pytest.mark.asyncio
+async def test_purge_leaves_rows_of_existing_providers_alone(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await service._purge_orphans()
+    assert len(await rows(factory)) == 1
+
+
+@pytest.mark.asyncio
+async def test_inactive_printer_never_gets_widget_and_existing_widget_is_removed(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    async with factory() as db:
+        (await db.get(Printer, 1)).is_active = False
+        await db.commit()
+    await service.tick()
+    api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+    await service.tick()
+    assert api.create_widget.await_count == 1
+
+
+@pytest.mark.asyncio
+async def test_capacity_refusal_waits_for_provider_save_instead_of_forever_retrying(setup):
+    service, api, factory, clock, states = setup
+    api.create_widget.side_effect = NotifyError(
+        "capacity", status_code=400, payload={"message": "Maximum of 10 widgets"}
+    )
+    await service.tick()
+    for _ in range(3):
+        clock[0] += timedelta(hours=1)
+        await service.tick()
+    assert api.create_widget.await_count == 1
+    assert (await rows(factory))[0].state == "capacity"
+    service.providers_changed()
+    api.create_widget.side_effect = None
+    await service.tick()
+    assert api.create_widget.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_all_printers_is_bounded_to_ten_local_widgets(setup):
+    service, api, factory, clock, states = setup
+    async with factory() as db:
+        for printer_id in range(2, 13):
+            db.add(
+                Printer(
+                    id=printer_id,
+                    name=f"Printer {printer_id}",
+                    serial_number=f"serial{printer_id}",
+                    ip_address="127.0.0.1",
+                    access_code="12345678",
+                )
+            )
+        await db.commit()
+    await service.tick()
+    assert api.create_widget.await_count == 10
+    for _ in range(3):
+        clock[0] += timedelta(hours=1)
+        await service.tick()
+    assert api.create_widget.await_count == 10
+    assert len(await rows(factory)) == 10
+
+
+@pytest.mark.asyncio
+async def test_delete_during_inflight_create_is_nonblocking_and_cleans_returned_handle(setup):
+    service, api, factory, clock, states = setup
+    entered, release = asyncio.Event(), asyncio.Event()
+
+    async def create(*args):
+        entered.set()
+        await release.wait()
+        return {"widgetId": "WG123456"}
+
+    api.create_widget.side_effect = create
+    tick = asyncio.create_task(service.tick())
+    await entered.wait()
+    await asyncio.wait_for(service.schedule_cleanup(1, {"device_id": "ABC12345", "token": "secret"}), 0.2)
+    async with factory() as db:
+        await db.delete(await db.get(NotificationProvider, 1))
+        await db.commit()
+    release.set()
+    await tick
+    try:
+        for _ in range(100):
+            if api.delete_widget.await_count:
+                break
+            await asyncio.sleep(0.005)
+        api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+        assert await rows(factory) == []
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+async def test_retiring_during_device_preflight_prevents_create(setup):
+    service, api, factory, clock, states = setup
+    entered, release = asyncio.Event(), asyncio.Event()
+
+    async def preflight(*args):
+        entered.set()
+        await release.wait()
+        return {"widgets": []}
+
+    api.list_widgets.side_effect = preflight
+    tick = asyncio.create_task(service.tick())
+    await entered.wait()
+    await service.schedule_cleanup(1, {"device_id": "ABC12345", "token": "secret"})
+    async with factory() as db:
+        await db.delete(await db.get(NotificationProvider, 1))
+        await db.commit()
+    release.set()
+    await tick
+    await service.close()
+    api.create_widget.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_boot_cleans_active_widget_for_disabled_provider_without_printer_queries(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    await configure(factory, lock_screen_widgets=False)
+    statements = []
+    engine = factory.kw["bind"]
+
+    def record(connection, cursor, statement, parameters, context, many):
+        statements.append(statement.lower())
+
+    event.listen(engine.sync_engine, "before_cursor_execute", record)
+    restarted = NotifyWidgetService(factory, api, states.get)
+    restarted.start()
+    try:
+        for _ in range(100):
+            if not await rows(factory):
+                break
+            await asyncio.sleep(0.005)
+        assert await rows(factory) == []
+        api.delete_widget.assert_awaited_once_with("WG123456", "secret")
+        assert not any("from printers" in statement for statement in statements)
+    finally:
+        await restarted.close()
+        event.remove(engine.sync_engine, "before_cursor_execute", record)
+
+
+@pytest.mark.asyncio
+async def test_cleanup_queued_during_same_generation_cleanup_is_not_lost(setup):
+    service, api, factory, clock, states = setup
+    await service.tick()
+    entered, release = asyncio.Event(), asyncio.Event()
+    batches = []
+
+    async def cleanup(provider_id, config, *, captured_rows):
+        batches.append(captured_rows)
+        if len(batches) == 1:
+            entered.set()
+            await release.wait()
+
+    service.cleanup_provider = cleanup
+    config = {"device_id": "ABC12345", "token": "secret"}
+    await service.schedule_cleanup(1, config)
+    await entered.wait()
+    await service.schedule_cleanup(1, config)
+    release.set()
+    try:
+        for _ in range(100):
+            if len(batches) == 2:
+                break
+            await asyncio.sleep(0.005)
+        assert len(batches) == 2
+        assert batches[0][0].widget_id == batches[1][0].widget_id == "WG123456"
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+async def test_transient_startup_database_failure_retries_then_becomes_dormant(setup):
+    service, api, factory, clock, states = setup
+    await configure(factory, lock_screen_widgets=False)
+    attempts = 0
+
+    class FailOnce:
+        async def __aenter__(self):
+            nonlocal attempts
+            attempts += 1
+            if attempts == 1:
+                raise RuntimeError("temporary database failure")
+            self.db = factory()
+            return await self.db.__aenter__()
+
+        async def __aexit__(self, *args):
+            await self.db.__aexit__(*args)
+
+    service._session = FailOnce
+    service._retry_delay = 0.001
+    service.start()
+    try:
+        for _ in range(100):
+            if not service._initial_discovery:
+                break
+            await asyncio.sleep(0.002)
+        assert service._provider_enabled is False
+        assert attempts >= 2
+        settled = attempts
+        await asyncio.sleep(0.02)
+        assert attempts == settled
+        api.create_widget.assert_not_awaited()
+    finally:
+        await service.close()
+
+
+@pytest.mark.asyncio
+async def test_existing_widget_success_does_not_clear_another_widgets_capacity_warning(setup):
+    service, api, factory, clock, states = setup
+    async with factory() as db:
+        db.add(Printer(id=2, name="Printer 2", serial_number="serial2", ip_address="127.0.0.2", access_code="12345678"))
+        await db.commit()
+    capacity = NotifyError("capacity", status_code=400, payload={"message": "Maximum of 10 widgets"})
+    api.create_widget.side_effect = [{"widgetId": "WG123456"}, capacity]
+    await service.tick()
+    states[1] = state(progress=43)
+    clock[0] += timedelta(minutes=2)
+    await service.tick()
+    api.update_widget.assert_awaited_once()
+    async with factory() as db:
+        provider = await db.get(NotificationProvider, 1)
+        assert provider.last_error.startswith("Notify! widget capacity: ")
+    service.providers_changed()
+    api.create_widget.side_effect = None
+    api.create_widget.return_value = {"widgetId": "WG654321"}
+    await service.tick()
+    async with factory() as db:
+        assert (await db.get(NotificationProvider, 1)).last_error is None

+ 72 - 0
backend/tests/unit/test_notify_callback_isolation.py

@@ -0,0 +1,72 @@
+"""Optional Notify hooks must never interrupt essential printer callbacks."""
+
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app import main
+from backend.app.services.bambu_mqtt import PrinterState
+
+
+class ReachedNextStep(Exception):
+    """Stop a large callback once its essential post-hook action was reached."""
+
+
+async def test_status_broadcast_survives_notify_observer_failure(monkeypatch):
+    monkeypatch.setattr(main, "_last_status_broadcast", {})
+    monkeypatch.setattr(main, "_printer_last_connected", {})
+    monkeypatch.setattr(main, "_printer_reconciled_since_connect", {})
+    printer_manager = MagicMock()
+    printer_manager.get_printer.return_value = None
+    printer_manager.get_model.return_value = ""
+    broadcast = AsyncMock()
+    with (
+        patch.object(main.notify_live_activities, "observe", side_effect=RuntimeError("Notify failed")),
+        patch.object(main, "printer_manager", printer_manager),
+        patch.object(main.ws_manager, "send_printer_status", broadcast),
+        patch.object(main, "printer_state_to_dict", return_value={"state": "IDLE"}),
+        patch.object(main, "spawn_background_task", side_effect=lambda coro, **kwargs: coro.close()),
+    ):
+        await main.on_printer_status_change(42, PrinterState())
+    broadcast.assert_awaited_once()
+
+
+async def test_print_start_still_resets_milestones_when_notify_fails(monkeypatch):
+    milestones = {42: 75}
+    stopped = {42}
+    monkeypatch.setattr(main, "_last_progress_milestone", milestones)
+    monkeypatch.setattr(main, "_user_stopped_printers", stopped)
+    with (
+        patch.object(main.notify_live_activities, "print_started", side_effect=RuntimeError("Notify failed")),
+        patch.object(main.printer_manager, "get_status", return_value=PrinterState()),
+        patch.object(
+            main, "_kill_switch_notification_tasks", SimpleNamespace(pop=MagicMock(side_effect=ReachedNextStep))
+        ),
+        pytest.raises(ReachedNextStep),
+    ):
+        await main.on_print_start(42, {})
+    assert milestones[42] == 0
+    assert 42 not in stopped
+
+
+async def test_print_complete_still_requires_plate_clear_when_notify_fails(monkeypatch):
+    monkeypatch.setattr(main, "_kill_switch_notification_tasks", {})
+    monkeypatch.setattr(main, "_fallback_3mf_retry_tasks", {})
+    monkeypatch.setattr(main, "_user_stopped_printers", {42})
+    printer_manager = MagicMock()
+    printer_manager.set_awaiting_plate_clear.side_effect = ReachedNextStep
+    with (
+        patch.object(
+            main.notify_live_activities, "print_finished", side_effect=RuntimeError("Notify failed")
+        ) as notify,
+        patch.object(main, "printer_manager", printer_manager),
+        patch.object(main, "_recover_fallback_from_cache_before_eviction", AsyncMock()),
+        patch.object(main, "clear_3mf_cache"),
+        patch.object(main.ws_manager, "send_print_complete", AsyncMock()) as broadcast,
+        pytest.raises(ReachedNextStep),
+    ):
+        await main.on_print_complete(42, {"status": "failed"})
+    printer_manager.set_awaiting_plate_clear.assert_called_once_with(42, True)
+    assert notify.call_args.args[1]["status"] == "cancelled"
+    broadcast.assert_awaited_once()

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -125,7 +125,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Manyfold|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Notify!|Pushover|Bark|Gotify|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
 }
 

+ 306 - 0
frontend/src/__tests__/components/AddNotificationModal.test.tsx

@@ -18,6 +18,7 @@ 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(() => {
@@ -285,6 +286,7 @@ describe('AddNotificationModal — provider type list', () => {
       'Email',
       'Gotify',
       'Home Assistant',
+      'Notify!',
       'ntfy',
       'Pushover',
       'Telegram',
@@ -782,3 +784,307 @@ describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
     expect(onClose).not.toHaveBeenCalled();
   });
 });
+
+
+describe('AddNotificationModal — Notify!', () => {
+  const notifyProvider = (config: Record<string, unknown> = {}) => buildProvider({
+    name: 'My Notify!',
+    provider_type: 'notify',
+    config: { device_id: 'IO12345678901234', token: 'notify-secret', ...config },
+  });
+
+  const liveActivitiesSwitch = () => within(
+    screen.getByRole('group', { name: 'Live Activities (iOS)' }),
+  ).getByRole('switch');
+
+  const widgetsSwitch = () => within(
+    screen.getByRole('group', { name: 'Lock Screen Widgets (iOS)' }),
+  ).getByRole('switch');
+
+  const photoSwitch = () => screen.getAllByRole('switch').find(
+    (toggle) => toggle.parentElement?.textContent?.includes('Attach Photo'),
+  )!;
+
+  it('offers Notify! with masked credentials and Live Activities disabled by default', async () => {
+    const user = userEvent.setup();
+    render(<AddNotificationModal onClose={() => undefined} />);
+    await user.selectOptions(await screen.findByDisplayValue('Email'), 'notify');
+
+    expect(screen.getByLabelText(/device or group id/i)).toBeInTheDocument();
+    expect(screen.getByLabelText(/notify! token/i)).toHaveAttribute('type', 'password');
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(screen.queryByText('Live Activity appearance')).not.toBeInTheDocument();
+    expect(screen.getByText(/Use an iOS device ID; add a separate provider for group pushes/)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /test configuration/i })).toBeDisabled();
+    await user.type(screen.getByLabelText(/device or group id/i), 'GRP12345678901234');
+    expect(screen.getByRole('button', { name: /test configuration/i })).toBeDisabled();
+  });
+
+  it('creates a group push provider with Live Activities explicitly disabled', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(http.post('*/api/v1/notifications/', async ({ request }) => {
+      captured = await request.json() as Record<string, unknown>;
+      return HttpResponse.json({ id: 2 });
+    }));
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+    render(<AddNotificationModal onClose={onClose} />);
+    await user.selectOptions(await screen.findByDisplayValue('Email'), 'notify');
+    await user.type(screen.getByPlaceholderText(/My Notifications/i), 'Notify! group');
+    await user.type(screen.getByLabelText(/device or group id/i), 'GRP12345678901234');
+    await user.type(screen.getByLabelText(/notify! token/i), 'secret');
+    await user.click(screen.getByRole('button', { name: /^add$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({
+      provider_type: 'notify',
+      config: { device_id: 'GRP12345678901234', token: 'secret', live_activities: false },
+    });
+  });
+
+  it('saves tile customization with boolean and array types without enabling push events', 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={notifyProvider()} onClose={onClose} />);
+    await user.click(liveActivitiesSwitch());
+    expect(screen.getByText(/Event toggles below control push alerts only/)).toBeInTheDocument();
+    expect(screen.getByText(/Quiet hours block new activities; existing activities still update and end/)).toBeInTheDocument();
+    await user.click(screen.getByText('Live Activity appearance'));
+    await user.selectOptions(screen.getByLabelText('Hide file name on tile'), 'true');
+    await user.selectOptions(screen.getByLabelText('Show print stage'), 'true');
+    await user.selectOptions(screen.getByLabelText('Progress style'), 'segments');
+    await user.selectOptions(screen.getByLabelText('Time Sensitive problem alerts'), 'true');
+    await user.click(screen.getByRole('checkbox', { name: 'ETA' }));
+    await user.click(screen.getByRole('checkbox', { name: 'Bed' }));
+    await user.type(screen.getByLabelText('Dashboard URL (optional)'), 'https://bambuddy.example.com');
+    await user.type(screen.getByLabelText('Tint (optional)'), '#123456');
+    await user.type(screen.getByLabelText('Symbol (optional)'), 'printer');
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({
+      on_print_start: false,
+      on_print_progress: false,
+      config: {
+        live_activities: true,
+        live_activity_privacy: true,
+        live_activity_stage: true,
+        live_activity_style: 'segments',
+        live_activity_metrics: ['eta', 'bed'],
+        live_activity_button_url: 'https://bambuddy.example.com',
+        live_activity_tint: '#123456',
+        live_activity_symbol: 'printer',
+        time_sensitive: true,
+      },
+    });
+  });
+
+  it('preserves existing customization while disabling Live Activities', 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={notifyProvider({
+      live_activities: true,
+      live_activity_privacy: true,
+      live_activity_style: 'none',
+      live_activity_metrics: ['layers', 'nozzle'],
+      time_sensitive: false,
+    })} onClose={onClose} />);
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'true');
+    await user.click(screen.getByText('Live Activity appearance'));
+    expect(screen.getByLabelText('Hide file name on tile')).toHaveValue('true');
+    expect(screen.getByLabelText('Progress style')).toHaveValue('none');
+    expect(screen.getByRole('checkbox', { name: 'Layer' })).toBeChecked();
+    expect(screen.getByRole('checkbox', { name: 'Nozzle' })).toBeChecked();
+    await user.click(liveActivitiesSwitch());
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({ config: {
+      live_activities: false,
+      live_activity_privacy: true,
+      live_activity_style: 'none',
+      live_activity_metrics: ['layers', 'nozzle'],
+      time_sensitive: false,
+    } });
+  });
+
+  it.each(['GRP12345678901234', 'wb12345678901234', 'MC12345678901234'])('keeps %s eligible for pushes with Live Activities disabled', async (deviceId) => {
+    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={notifyProvider({ device_id: deviceId, live_activities: true, lock_screen_widgets: true })} onClose={onClose} />);
+    expect(liveActivitiesSwitch()).toBeDisabled();
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(widgetsSwitch()).toBeDisabled();
+    expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(screen.getByText(/Group, browser and macOS IDs use push notifications only/)).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: /test configuration/i })).toBeEnabled();
+    expect(screen.queryByText('Live Activity appearance')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({ config: {
+      device_id: deviceId,
+      token: 'notify-secret',
+      live_activities: false,
+      lock_screen_widgets: false,
+    } });
+  });
+
+  it.each(['GRP12345678901234', 'WB12345678901234'])('turns off Live Activities when changing an iOS ID to %s', async (deviceId) => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(http.post('*/api/v1/notifications/test-config', async ({ request }) => {
+      captured = await request.json() as Record<string, unknown>;
+      return HttpResponse.json({ success: true, message: 'Push delivered' });
+    }));
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={notifyProvider({ live_activities: true, lock_screen_widgets: true })} onClose={() => undefined} />);
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'true');
+    expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'true');
+    const target = screen.getByLabelText(/device or group id/i);
+    await user.clear(target);
+    await user.type(target, deviceId);
+    expect(liveActivitiesSwitch()).toBeDisabled();
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(widgetsSwitch()).toBeDisabled();
+    expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(photoSwitch()).toBeDisabled();
+    expect(photoSwitch()).toHaveAttribute('aria-checked', 'false');
+    await user.click(screen.getByRole('button', { name: /test configuration/i }));
+    expect(await screen.findByText('Push delivered')).toBeInTheDocument();
+    expect(captured).toMatchObject({ attach_photo: false });
+    expect(captured).toMatchObject({ config: {
+      device_id: deviceId,
+      token: 'notify-secret',
+      live_activities: false,
+      lock_screen_widgets: false,
+    } });
+    await user.clear(target);
+    await user.type(target, 'IO12345678901234');
+    expect(liveActivitiesSwitch()).toBeEnabled();
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
+  });
+
+  it('sends setup tests through the push test endpoint with typed config', async () => {
+    let captured: Record<string, unknown> | null = null;
+    server.use(http.post('*/api/v1/notifications/test-config', async ({ request }) => {
+      captured = await request.json() as Record<string, unknown>;
+      return HttpResponse.json({ success: true, message: 'Push delivered' });
+    }));
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={notifyProvider({ live_activities: true })} onClose={() => undefined} />);
+    expect(screen.getByText(/Testing sends a push notification/)).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /test configuration/i }));
+    expect(await screen.findByText('Push delivered')).toBeInTheDocument();
+    expect(captured).toMatchObject({
+      provider_type: 'notify',
+      config: { device_id: 'IO12345678901234', token: 'notify-secret', live_activities: true, live_activity_metrics: [] },
+    });
+  });
+
+  it('clears Notify customization when changing provider type', async () => {
+    const user = userEvent.setup();
+    render(<AddNotificationModal onClose={() => undefined} />);
+    const providerSelect = await screen.findByDisplayValue('Email');
+    await user.selectOptions(providerSelect, 'notify');
+    await user.click(liveActivitiesSwitch());
+    await user.click(screen.getByText('Live Activity appearance'));
+    await user.click(screen.getByRole('checkbox', { name: 'ETA' }));
+    await user.selectOptions(providerSelect, 'ntfy');
+    await user.selectOptions(providerSelect, 'notify');
+    expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
+    await user.click(liveActivitiesSwitch());
+    await user.click(screen.getByText('Live Activity appearance'));
+    expect(screen.getByRole('checkbox', { name: 'ETA' })).not.toBeChecked();
+  });
+
+  it('defaults to the timer with optional metrics unchecked', async () => {
+    const user = userEvent.setup();
+    render(<AddNotificationModal provider={notifyProvider({ live_activities: true })} onClose={() => undefined} />);
+    await user.click(screen.getByText('Live Activity appearance'));
+    expect(screen.getByText(/The timer is shown by default/)).toBeInTheDocument();
+    for (const checkbox of screen.getAllByRole('checkbox')) expect(checkbox).not.toBeChecked();
+  });
+
+  it('enables Lock Screen widgets independently of Live Activities and push events', 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={notifyProvider()} onClose={onClose} />);
+    expect(screen.getByText(/iOS refreshes roughly every 15 minutes or longer/)).toBeInTheDocument();
+    expect(screen.getByText(/Only active printers outside maintenance mode receive widgets/)).toBeInTheDocument();
+    expect(screen.getByText(/Notify! allows 10 widgets per device in total/)).toBeInTheDocument();
+    expect(screen.getByDisplayValue('All printers')).toBeEnabled();
+    await user.click(widgetsSwitch());
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({
+      on_print_start: false,
+      on_print_progress: false,
+      printer_id: null,
+      config: { token: 'notify-secret', live_activities: false, lock_screen_widgets: true },
+    });
+  });
+
+  it.each(['GRP12345678901234', 'WB12345678901234'])('saves %s with photo attachments disabled', async (deviceId) => {
+    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={notifyProvider({ device_id: deviceId })} onClose={onClose} />);
+    expect(photoSwitch()).toBeDisabled();
+    expect(photoSwitch()).toHaveAttribute('aria-checked', 'false');
+    expect(screen.getByText(/Browser and group recipients support text notifications only/)).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /^save$/i }));
+    await waitFor(() => expect(onClose).toHaveBeenCalled());
+    expect(captured).toMatchObject({ attach_photo: false, config: { device_id: deviceId, token: 'notify-secret' } });
+  });
+
+  it.each(['GRP12345678901234', 'WB12345678901234'])('disables photo attachments in the %s provider card', async (deviceId) => {
+    const user = userEvent.setup();
+    render(<NotificationProviderCard provider={notifyProvider({ device_id: deviceId, live_activities: true, lock_screen_widgets: true })} onEdit={() => undefined} />);
+    expect(screen.queryByText('Live Activities (iOS)')).not.toBeInTheDocument();
+    expect(screen.queryByText('Lock Screen Widgets (iOS)')).not.toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /event settings/i }));
+    expect(photoSwitch()).toBeDisabled();
+    expect(photoSwitch()).toHaveAttribute('aria-checked', 'false');
+  });
+
+  it('shows a separate badge when Lock Screen widgets are enabled', () => {
+    render(<NotificationProviderCard provider={notifyProvider({ lock_screen_widgets: true })} onEdit={() => undefined} />);
+    expect(screen.getByText('Lock Screen Widgets (iOS)')).toBeInTheDocument();
+    expect(screen.queryByText('Live Activities (iOS)')).not.toBeInTheDocument();
+  });
+
+  it('shows the Live Activities badge on enabled Notify! provider cards', () => {
+    render(<NotificationProviderCard provider={notifyProvider({ live_activities: true })} onEdit={() => undefined} />);
+    expect(screen.getByText('Notify!')).toBeInTheDocument();
+    expect(screen.getByText('Live Activities (iOS)')).toBeInTheDocument();
+  });
+
+  it('does not show the Live Activities badge for push-only providers', () => {
+    render(<NotificationProviderCard provider={notifyProvider({ live_activities: false })} onEdit={() => undefined} />);
+    expect(screen.queryByText('Live Activities (iOS)')).not.toBeInTheDocument();
+  });
+});

+ 18 - 1
frontend/src/api/client.ts

@@ -3154,7 +3154,7 @@ export interface Filament {
 }
 
 // Notification Provider types
-export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark' | 'gotify';
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook' | 'homeassistant' | 'bark' | 'gotify' | 'notify';
 // How a Telegram provider collects the outcome verdict (#3046)
 export type TelegramVerdictMode = 'buttons' | 'reactions' | 'both';
 
@@ -3635,6 +3635,23 @@ export interface PushoverConfig {
   priority?: number;
 }
 
+export interface NotifyConfig {
+  device_id: string;
+  token: string;
+  icon_url?: string;
+  group_type?: string;
+  live_activities?: boolean;
+  lock_screen_widgets?: boolean;
+  live_activity_privacy?: boolean;
+  live_activity_stage?: boolean;
+  live_activity_style?: 'bar' | 'segments' | 'none';
+  live_activity_metrics?: ('progress' | 'eta' | 'layers' | 'nozzle' | 'bed' | 'chamber')[];
+  live_activity_button_url?: string;
+  live_activity_symbol?: string;
+  live_activity_tint?: string;
+  time_sensitive?: boolean;
+}
+
 export interface TelegramConfig {
   bot_token: string;
   chat_id: string;

+ 224 - 52
frontend/src/components/AddNotificationModal.tsx

@@ -6,13 +6,30 @@ import { api } from '../api/client';
 import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType, TelegramVerdictMode } from '../api/client';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
+import { isNotifyPushOnlyTarget, isNotifyPhotoUnsupported } from '../utils/notify';
+
+interface ConfigField {
+  key: string;
+  label: string;
+  type: string;
+  required: boolean;
+  placeholder?: string;
+  help?: string;
+  options?: { value: string; label: string }[];
+  showIf?: (config: Record<string, string>) => boolean;
+  advanced?: boolean;
+  pattern?: string;
+}
+
+const NOTIFY_METRICS = ['progress', 'eta', 'layers', 'nozzle', 'bed', 'chamber'] as const;
+type NotifyMetric = (typeof NOTIFY_METRICS)[number];
 
 interface AddNotificationModalProps {
   provider?: NotificationProvider | null;
   onClose: () => void;
 }
 
-const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'gotify', 'callmebot', 'webhook', 'homeassistant'];
+const PROVIDER_VALUES: ProviderType[] = ['email', 'telegram', 'discord', 'ntfy', 'pushover', 'bark', 'gotify', 'callmebot', 'webhook', 'homeassistant', 'notify'];
 
 export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {
   const { t, i18n } = useTranslation();
@@ -22,7 +39,11 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [name, setName] = useState(provider?.name || '');
   const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type || 'email');
   const [printerId, setPrinterId] = useState<number | null>(provider?.printer_id || null);
-  const [attachPhoto, setAttachPhoto] = useState(provider?.attach_photo ?? true);
+  const [attachPhoto, setAttachPhoto] = useState(
+    provider?.provider_type === 'notify' && isNotifyPhotoUnsupported(String(provider.config.device_id || ''))
+      ? false
+      : provider?.attach_photo ?? true
+  );
   const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);
   const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');
   const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');
@@ -61,13 +82,26 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
   const [onAppMessage, setOnAppMessage] = useState(provider?.on_app_message ?? false);
 
+  const [notifyLiveActivities, setNotifyLiveActivities] = useState(
+    provider?.config?.live_activities === true && !isNotifyPushOnlyTarget(String(provider?.config?.device_id || ''))
+  );
+
+  const [notifyLockScreenWidgets, setNotifyLockScreenWidgets] = useState(
+    provider?.config?.lock_screen_widgets === true && !isNotifyPushOnlyTarget(String(provider?.config?.device_id || ''))
+  );
+
+  const [notifyMetrics, setNotifyMetrics] = useState<NotifyMetric[]>(() => {
+    const metrics = provider?.config?.live_activity_metrics;
+    return Array.isArray(metrics) ? NOTIFY_METRICS.filter((metric) => metrics.includes(metric)) : [];
+  });
+
   // Provider-specific config (scalar fields only — event_priorities is split out
   // into its own state because it's an object, not a string).
   const [config, setConfig] = useState<Record<string, string>>(
     provider?.config
       ? Object.fromEntries(
           Object.entries(provider.config)
-            .filter(([k]) => k !== 'event_priorities')
+            .filter(([k]) => k !== 'event_priorities' && k !== 'live_activities' && k !== 'live_activity_metrics' && k !== 'lock_screen_widgets')
             .map(([k, v]) => [k, String(v)]),
         )
       : {},
@@ -105,9 +139,23 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
     return () => window.removeEventListener('keydown', handleKeyDown);
   }, [onClose]);
 
+  const notifyPushOnlyTarget = providerType === 'notify' && isNotifyPushOnlyTarget(config.device_id || '');
+  const notifyPhotosUnsupported = providerType === 'notify' && isNotifyPhotoUnsupported(config.device_id || '');
+  const configForRequest = () => providerType === 'notify'
+    ? {
+        ...config,
+        live_activities: notifyLiveActivities && !notifyPushOnlyTarget,
+        lock_screen_widgets: notifyLockScreenWidgets && !notifyPushOnlyTarget,
+        live_activity_privacy: config.live_activity_privacy === 'true',
+        live_activity_stage: config.live_activity_stage === 'true',
+        live_activity_metrics: notifyMetrics,
+        time_sensitive: config.time_sensitive === 'true',
+      }
+    : config;
+
   // Test configuration mutation
   const testMutation = useMutation({
-    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config, attach_photo: attachPhoto }),
+    mutationFn: () => api.testNotificationConfig({ provider_type: providerType, config: configForRequest(), attach_photo: attachPhoto && !notifyPhotosUnsupported }),
     onSuccess: (result) => {
       setTestResult(result);
       setError(null);
@@ -186,14 +234,14 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
     const finalConfig: Record<string, unknown> =
       (providerType === 'ntfy' || providerType === 'gotify') && Object.keys(eventPriorities).length > 0
         ? { ...config, event_priorities: eventPriorities }
-        : config;
+        : configForRequest();
 
     const data = {
       name: name.trim(),
       provider_type: providerType,
       config: finalConfig,
       printer_id: printerId,
-      attach_photo: attachPhoto,
+      attach_photo: attachPhoto && !notifyPhotosUnsupported,
       quiet_hours_enabled: quietHoursEnabled,
       quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,
       quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,
@@ -234,7 +282,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const isPending = createMutation.isPending || updateMutation.isPending;
 
   // Get config fields for each provider type
-  const getConfigFields = (type: ProviderType) => {
+  const getConfigFields = (type: ProviderType): ConfigField[] => {
     switch (type) {
       case 'callmebot':
         return [
@@ -271,6 +319,38 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
             showIf: (cfg: Record<string, string>) => cfg.priority === '2',
           },
         ];
+      case 'notify':
+        return [
+          { key: 'device_id', label: t('notifications.notifyDeviceId'), type: 'text', required: true },
+          { key: 'token', label: t('notifications.notifyToken'), type: 'password', required: true },
+          { key: 'icon_url', label: t('notifications.notifyIconUrl'), placeholder: 'https://example.com/icon.png', type: 'url', pattern: 'https://.*', required: false },
+          { key: 'group_type', label: t('notifications.notifyGroupType'), type: 'text', required: false, help: t('notifications.notifyGroupTypeHelp') },
+          {
+            key: 'time_sensitive', label: t('notifications.notifyTimeSensitive'), type: 'select', required: false,
+            options: [{ value: 'false', label: t('common.no') }, { value: 'true', label: t('common.yes') }],
+            help: t('notifications.notifyTimeSensitiveHelp'),
+          },
+          {
+            key: 'live_activity_privacy', label: t('notifications.notifyPrivacy'), type: 'select', required: false, advanced: true,
+            options: [{ value: 'false', label: t('common.no') }, { value: 'true', label: t('common.yes') }],
+            help: t('notifications.notifyPrivacyHelp'),
+          },
+          {
+            key: 'live_activity_stage', label: t('notifications.notifyShowStage'), type: 'select', required: false, advanced: true,
+            options: [{ value: 'false', label: t('common.no') }, { value: 'true', label: t('common.yes') }],
+          },
+          {
+            key: 'live_activity_style', label: t('notifications.notifyStyle'), type: 'select', required: false, advanced: true,
+            options: [
+              { value: 'bar', label: t('notifications.notifyStyleBar') },
+              { value: 'segments', label: t('notifications.notifyStyleSegments') },
+              { value: 'none', label: t('notifications.notifyStyleNone') },
+            ],
+          },
+          { key: 'live_activity_button_url', label: t('notifications.notifyDashboardUrl'), type: 'url', pattern: 'https://.*', placeholder: 'https://bambuddy.example.com', required: false, advanced: true },
+          { key: 'live_activity_symbol', label: t('notifications.notifySymbol'), type: 'text', placeholder: 'printer.fill', required: false, advanced: true },
+          { key: 'live_activity_tint', label: t('notifications.notifyTint'), type: 'text', placeholder: '#00AE42', pattern: '#[0-9a-fA-F]{6}', required: false, advanced: true },
+        ];
       case 'telegram':
         return [
           { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },
@@ -361,6 +441,69 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
 
   const configFields = getConfigFields(providerType);
 
+  const renderConfigFields = (fields: ConfigField[]) =>
+    fields
+      .filter((field) => field.showIf?.(config) !== false)
+      .map((field) => (
+        <div key={field.key}>
+          <label htmlFor={`notification-config-${field.key}`} className="block text-sm text-bambu-gray mb-1">
+            {field.label} {field.required && '*'}
+          </label>
+          {field.type === 'select' && field.options ? (
+            <select
+              id={`notification-config-${field.key}`}
+              value={config[field.key] || field.options[0]?.value || ''}
+              onChange={(e) => {
+                setConfig({ ...config, [field.key]: e.target.value });
+                setTestResult(null);
+              }}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            >
+              {field.options.map((opt) => (
+                <option key={opt.value} value={opt.value}>
+                  {opt.label}
+                </option>
+              ))}
+            </select>
+          ) : field.type === 'textarea' ? (
+            <textarea
+              id={`notification-config-${field.key}`}
+              value={config[field.key] || ''}
+              onChange={(e) => {
+                setConfig({ ...config, [field.key]: e.target.value });
+                setTestResult(null);
+              }}
+              placeholder={field.placeholder}
+              rows={3}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono text-sm"
+            />
+          ) : (
+            <input
+              id={`notification-config-${field.key}`}
+              type={field.type}
+              pattern={field.pattern}
+              value={config[field.key] || ''}
+              onChange={(e) => {
+                setConfig({ ...config, [field.key]: e.target.value });
+                if (providerType === 'notify' && field.key === 'device_id' && isNotifyPushOnlyTarget(e.target.value)) {
+                  setNotifyLiveActivities(false);
+                  setNotifyLockScreenWidgets(false);
+                }
+                if (providerType === 'notify' && field.key === 'device_id' && isNotifyPhotoUnsupported(e.target.value)) {
+                  setAttachPhoto(false);
+                }
+                setTestResult(null);
+              }}
+              placeholder={field.placeholder}
+              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
+            />
+          )}
+          {field.help && (
+            <p className="text-xs text-bambu-gray mt-1">{field.help}</p>
+          )}
+        </div>
+      ));
+
   return (
     <div
       className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"
@@ -411,6 +554,9 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
               onChange={(e) => {
                 setProviderType(e.target.value as ProviderType);
                 setConfig({}); // Reset config when changing type
+                setNotifyLiveActivities(false);
+                setNotifyLockScreenWidgets(false);
+                setNotifyMetrics([]);
                 setTestResult(null);
               }}
               disabled={isEditing}
@@ -433,56 +579,81 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           {/* Provider-specific configuration */}
           <div className="space-y-3">
             <p className="text-sm text-bambu-gray">{t('notifications.configuration')}</p>
-            {configFields
-              .filter((field) => !('showIf' in field) || (field as { showIf?: (cfg: Record<string, string>) => boolean }).showIf?.(config) !== false)
-              .map((field) => (
-              <div key={field.key}>
-                <label className="block text-sm text-bambu-gray mb-1">
-                  {field.label} {field.required && '*'}
-                </label>
-                {field.type === 'select' && 'options' in field && field.options ? (
-                  <select
-                    value={config[field.key] || field.options[0]?.value || ''}
-                    onChange={(e) => {
-                      setConfig({ ...config, [field.key]: e.target.value });
-                      setTestResult(null);
-                    }}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                  >
-                    {field.options.map((opt) => (
-                      <option key={opt.value} value={opt.value}>
-                        {opt.label}
-                      </option>
-                    ))}
-                  </select>
-                ) : field.type === 'textarea' ? (
-                  <textarea
-                    value={config[field.key] || ''}
-                    onChange={(e) => {
-                      setConfig({ ...config, [field.key]: e.target.value });
+            {renderConfigFields(configFields.filter((field) => !field.advanced))}
+            {providerType === 'notify' && (
+              <div className="space-y-2">
+                <div className="flex items-center justify-between gap-3" role="group" aria-label={t('notifications.notifyLiveActivities')}>
+                  <div>
+                    <label className="text-sm text-white">{t('notifications.notifyLiveActivities')}</label>
+                    <p className="text-xs text-bambu-gray">{t('notifications.notifyLiveActivitiesHelp')}</p>
+                  </div>
+                  <Toggle
+                    checked={notifyLiveActivities && !notifyPushOnlyTarget}
+                    disabled={notifyPushOnlyTarget}
+                    onChange={(checked) => {
+                      setNotifyLiveActivities(checked);
                       setTestResult(null);
+                      setError(null);
                     }}
-                    placeholder={field.placeholder}
-                    rows={3}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono text-sm"
                   />
-                ) : (
-                  <input
-                    type={field.type}
-                    value={config[field.key] || ''}
-                    onChange={(e) => {
-                      setConfig({ ...config, [field.key]: e.target.value });
+                </div>
+                {notifyLiveActivities && (
+                  <>
+                    <p className="text-xs text-bambu-gray">{t('notifications.notifyLifecycleHelp')}</p>
+                    <details className="rounded-lg border border-bambu-dark-tertiary p-3">
+                      <summary className="cursor-pointer text-sm text-white">{t('notifications.notifyAppearance')}</summary>
+                      <div className="mt-3 space-y-3">
+                        {renderConfigFields(configFields.filter((field) => field.advanced))}
+                        <fieldset>
+                          <legend className="text-sm text-bambu-gray mb-1">{t('notifications.notifyMetrics')}</legend>
+                          <p className="text-xs text-bambu-gray mb-2">{t('notifications.notifyMetricsHelp')}</p>
+                          <div className="flex flex-wrap gap-3">
+                            {NOTIFY_METRICS.map((metric) => (
+                              <label key={metric} className="flex items-center gap-1 text-sm text-white">
+                                <input
+                                  type="checkbox"
+                                  checked={notifyMetrics.includes(metric)}
+                                  onChange={(e) => {
+                                    setNotifyMetrics(e.target.checked
+                                      ? [...notifyMetrics, metric]
+                                      : notifyMetrics.filter((selected) => selected !== metric));
+                                    setTestResult(null);
+                                  }}
+                                  className="accent-bambu-green"
+                                />
+                                {t(metric === 'progress' ? 'notifications.progress'
+                                  : metric === 'eta' ? 'streamOverlay.eta'
+                                  : metric === 'layers' ? 'streamOverlay.layer'
+                                  : `printers.temperatures.${metric}`)}
+                              </label>
+                            ))}
+                          </div>
+                        </fieldset>
+                      </div>
+                    </details>
+                  </>
+                )}
+                <div className="flex items-center justify-between gap-3" role="group" aria-label={t('notifications.notifyLockScreenWidgets')}>
+                  <div>
+                    <label className="text-sm text-white">{t('notifications.notifyLockScreenWidgets')}</label>
+                    <p className="text-xs text-bambu-gray">{t('notifications.notifyLockScreenWidgetsHelp')}</p>
+                  </div>
+                  <Toggle
+                    checked={notifyLockScreenWidgets && !notifyPushOnlyTarget}
+                    disabled={notifyPushOnlyTarget}
+                    onChange={(checked) => {
+                      setNotifyLockScreenWidgets(checked);
                       setTestResult(null);
+                      setError(null);
                     }}
-                    placeholder={field.placeholder}
-                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
+                </div>
+                {notifyPushOnlyTarget && (
+                  <p className="text-xs text-bambu-gray">{t('notifications.notifyIosFeaturesUnavailable')}</p>
                 )}
-                {'help' in field && (field as { help?: string }).help && (
-                  <p className="text-xs text-bambu-gray mt-1">{(field as { help?: string }).help}</p>
-                )}
+                <p className="text-xs text-bambu-gray">{t('notifications.notifyTestHelp')}</p>
               </div>
-            ))}
+            )}
             {providerType === 'telegram' && (
               <div>
                 <label htmlFor="telegram-verdict-mode" className="block text-sm text-bambu-gray mb-1">
@@ -512,7 +683,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                 setTestResult(null);
                 testMutation.mutate();
               }}
-              disabled={testMutation.isPending || (getRequiredFields(providerType).length > 0 && !config[getRequiredFields(providerType)[0]?.key])}
+              disabled={testMutation.isPending || getRequiredFields(providerType).some((field) => !config[field.key]?.trim())}
               className="flex-1"
             >
               {testMutation.isPending ? (
@@ -569,10 +740,11 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
           <div className="flex items-center justify-between">
             <div>
               <label className="text-sm text-white">{t('notifications.attachPhotoLabel')}</label>
-              <p className="text-xs text-bambu-gray">{t('notifications.attachPhotoDescription')}</p>
+              <p className="text-xs text-bambu-gray">{t(notifyPhotosUnsupported ? 'notifications.notifyPhotoUnavailable' : 'notifications.attachPhotoDescription')}</p>
             </div>
             <Toggle
-              checked={attachPhoto}
+              checked={attachPhoto && !notifyPhotosUnsupported}
+              disabled={notifyPhotosUnsupported}
               onChange={setAttachPhoto}
             />
           </div>

+ 13 - 3
frontend/src/components/NotificationProviderCard.tsx

@@ -9,6 +9,7 @@ import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { Toggle } from './Toggle';
+import { isNotifyPushOnlyTarget, isNotifyPhotoUnsupported } from '../utils/notify';
 
 interface NotificationProviderCardProps {
   provider: NotificationProvider;
@@ -28,6 +29,8 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
     queryFn: api.getPrinters,
   });
 
+  const notifyPhotosUnsupported = provider.provider_type === 'notify' && isNotifyPhotoUnsupported(String(provider.config.device_id || ''));
+
   const linkedPrinter = printers?.find(p => p.id === provider.printer_id);
 
   // Update mutation
@@ -114,6 +117,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
 
           {/* Event summary - show all event tags */}
           <div className="mb-3 flex flex-wrap gap-1">
+            {provider.provider_type === 'notify' && provider.config.live_activities === true && !isNotifyPushOnlyTarget(String(provider.config.device_id || '')) && (
+              <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.notifyLiveActivities')}</span>
+            )}
+            {provider.provider_type === 'notify' && provider.config.lock_screen_widgets === true && !isNotifyPushOnlyTarget(String(provider.config.device_id || '')) && (
+              <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.notifyLockScreenWidgets')}</span>
+            )}
             {provider.on_print_start && (
               <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.start')}</span>
             )}
@@ -213,7 +222,7 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                 {t('notifications.digest', { time: provider.daily_digest_time })}
               </span>
             )}
-            {provider.attach_photo === false && (
+            {(provider.attach_photo === false || notifyPhotosUnsupported) && (
               <span className="px-2 py-0.5 bg-bambu-dark-tertiary text-bambu-gray text-xs rounded flex items-center gap-1">
                 <CameraOff className="w-3 h-3" />
                 {t('notifications.noPhoto')}
@@ -733,11 +742,12 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   <Camera className="w-4 h-4 text-bambu-gray" />
                   <div>
                     <p className="text-sm text-white">{t('notifications.attachPhotoLabel')}</p>
-                    <p className="text-xs text-bambu-gray">{t('notifications.attachPhotoDescription')}</p>
+                    <p className="text-xs text-bambu-gray">{t(notifyPhotosUnsupported ? 'notifications.notifyPhotoUnavailable' : 'notifications.attachPhotoDescription')}</p>
                   </div>
                 </div>
                 <Toggle
-                  checked={provider.attach_photo ?? true}
+                  checked={!notifyPhotosUnsupported && (provider.attach_photo ?? true)}
+                  disabled={notifyPhotosUnsupported}
                   onChange={(checked) => updateMutation.mutate({ attach_photo: checked })}
                 />
               </div>

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

@@ -6574,8 +6574,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Sperrbildschirm-Widgets (iOS)',
+    notifyLockScreenWidgetsHelp: 'Nur aktive Drucker außerhalb des Wartungsmodus erhalten Widgets. Notify! erlaubt insgesamt 10 Widgets pro Gerät. Füge über die iOS-Sperrbildschirmanpassung ein Notify!-Widget hinzu und wähle einen Drucker. Es zeigt Status, Fortschritt und Probleme ohne laufenden Countdown. iOS aktualisiert etwa alle 15 Minuten oder seltener.',
+    notifyPhotoUnavailable: 'Browser- und Gruppenempfänger unterstützen nur Textmitteilungen; Fotoanhänge sind nicht verfügbar.',
+    notifyShowStage: 'Druckphase anzeigen',
+    notifyTimeSensitive: 'Dringliche Problemmeldungen',
+    notifyTimeSensitiveHelp: 'Markiert fehlgeschlagene oder gestoppte Drucke sowie Drucker- oder KI-Probleme als dringlich. Andere Push-Mitteilungen bleiben normal.',
+    notifyPrivacy: 'Dateiname auf Kachel ausblenden',
+    notifyPrivacyHelp: 'Blendet Dateinamen nur in der Live-Aktivität aus. Push-Texte verwenden weiterhin die Mitteilungsvorlagen.',
+    notifyStyle: 'Fortschrittsdarstellung',
+    notifyStyleBar: 'Fortschrittsbalken',
+    notifyStyleSegments: 'Segmente',
+    notifyStyleNone: 'Keine Fortschrittsanzeige',
+    notifyDashboardUrl: 'Dashboard-URL (optional)',
+    notifySymbol: 'Symbolname (optional)',
+    notifyTint: 'Akzentfarbe (optional)',
+    notifyAppearance: 'Darstellung der Live-Aktivität',
+    notifyMetrics: 'Messwerte',
+    notifyMetricsHelp: 'Der Timer wird standardmäßig angezeigt. Optionale Messwerte ersetzen die Dateinamenzeile.',
+    notifyDeviceId: 'Geräte- oder Gruppen-ID',
+    notifyToken: 'Notify!-Token',
+    notifyIconUrl: 'Symbol-URL (optional)',
+    notifyGroupType: 'Mitteilungsgruppe (optional)',
+    notifyGroupTypeHelp: 'Leer lassen, um Push-Mitteilungen nach Drucker zu gruppieren. Ein eigener Wert fasst sie in derselben Gruppe zusammen.',
+    notifyLiveActivities: 'Live-Aktivitäten (iOS)',
+    notifyLiveActivitiesHelp: 'Verfolge Drucke auf dem iOS-Sperrbildschirm und in der Dynamic Island. Verwende eine iOS-Geräte-ID und einen separaten Anbieter für Gruppenmitteilungen.',
+    notifyLifecycleHelp: 'Live-Aktivitäten begleiten jeden Druck automatisch. Die Ereignisschalter unten steuern nur Push-Mitteilungen. Ruhezeiten verhindern neue Aktivitäten; bestehende werden weiter aktualisiert und beendet. Tageszusammenfassungen verzögern keine Aktivitäten.',
+    notifyIosFeaturesUnavailable: 'Live-Aktivitäten und Sperrbildschirm-Widgets benötigen eine iOS-Geräte-ID. Gruppen-, Browser- und macOS-IDs nutzen nur Push-Mitteilungen.',
+    notifyTestHelp: 'Der Test sendet eine Push-Mitteilung. Eine Live-Aktivität beginnt beim Druckstart.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6589,6 +6618,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Push-Mitteilungen und iOS-Live-Aktivitäten über Notify!',
       email: 'SMTP-E-Mail-Benachrichtigungen',
       telegram: 'Benachrichtigungen über Telegram-Bot',
       discord: 'An Discord-Kanal per Webhook senden',

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

@@ -6627,8 +6627,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Lock Screen Widgets (iOS)',
+    notifyLockScreenWidgetsHelp: 'Only active printers outside maintenance mode receive widgets. Notify! allows 10 widgets per device in total. Add a Notify! widget from iOS Customize Lock Screen and select a printer. It shows status, progress and problems without a live countdown. iOS refreshes roughly every 15 minutes or longer.',
+    notifyPhotoUnavailable: 'Browser and group recipients support text notifications only; photo attachments are unavailable.',
+    notifyShowStage: 'Show print stage',
+    notifyTimeSensitive: 'Time Sensitive problem alerts',
+    notifyTimeSensitiveHelp: 'Mark failed or stopped prints and printer or AI problems as Time Sensitive. Other pushes stay normal.',
+    notifyPrivacy: 'Hide file name on tile',
+    notifyPrivacyHelp: 'Hides file names only in the Live Activity. Push text still uses notification templates.',
+    notifyStyle: 'Progress style',
+    notifyStyleBar: 'Progress bar',
+    notifyStyleSegments: 'Segments',
+    notifyStyleNone: 'No progress indicator',
+    notifyDashboardUrl: 'Dashboard URL (optional)',
+    notifySymbol: 'Symbol (optional)',
+    notifyTint: 'Tint (optional)',
+    notifyAppearance: 'Live Activity appearance',
+    notifyMetrics: 'Metrics',
+    notifyMetricsHelp: 'The timer is shown by default. Optional metrics replace the file-name line.',
+    notifyDeviceId: 'Device or group ID',
+    notifyToken: 'Notify! token',
+    notifyIconUrl: 'Icon URL (optional)',
+    notifyGroupType: 'Notification thread (optional)',
+    notifyGroupTypeHelp: 'Leave blank to group push notifications by printer. A custom value groups them in the same thread.',
+    notifyLiveActivities: 'Live Activities (iOS)',
+    notifyLiveActivitiesHelp: 'Track prints on your iOS Lock Screen and Dynamic Island. Use an iOS device ID; add a separate provider for group pushes.',
+    notifyLifecycleHelp: 'Live Activities follow each print automatically. Event toggles below control push alerts only. Quiet hours block new activities; existing activities still update and end. Daily digests do not delay activities.',
+    notifyIosFeaturesUnavailable: 'Live Activities and Lock Screen widgets require an iOS device ID. Group, browser and macOS IDs use push notifications only.',
+    notifyTestHelp: 'Testing sends a push notification. A Live Activity starts when a print starts.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6642,6 +6671,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Push notifications and iOS Live Activities via Notify!',
       email: 'SMTP email notifications',
       telegram: 'Notifications via Telegram bot',
       discord: 'Send to Discord channel via webhook',

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

@@ -6582,8 +6582,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Widgets de la pantalla de bloqueo (iOS)',
+    notifyLockScreenWidgetsHelp: 'Solo las impresoras activas fuera del modo de mantenimiento reciben widgets. Notify! permite un total de 10 widgets por dispositivo. Añade un widget de Notify! desde la personalización de la pantalla de bloqueo de iOS y selecciona una impresora. Muestra el estado, el progreso y los problemas sin cuenta atrás en directo. iOS actualiza aproximadamente cada 15 minutos o más.',
+    notifyPhotoUnavailable: 'Los destinatarios de navegador y grupo solo admiten notificaciones de texto; no se pueden adjuntar fotos.',
+    notifyShowStage: 'Mostrar fase de impresión',
+    notifyTimeSensitive: 'Alertas urgentes de problemas',
+    notifyTimeSensitiveHelp: 'Marca como urgentes las impresiones fallidas o detenidas y los problemas de la impresora o detectados por IA. Las demás notificaciones siguen siendo normales.',
+    notifyPrivacy: 'Ocultar nombre del archivo en la tarjeta',
+    notifyPrivacyHelp: 'Solo oculta los nombres de archivo en la actividad en directo. El texto de las notificaciones sigue usando las plantillas de notificación.',
+    notifyStyle: 'Estilo de progreso',
+    notifyStyleBar: 'Barra de progreso',
+    notifyStyleSegments: 'Segmentos',
+    notifyStyleNone: 'Sin indicador de progreso',
+    notifyDashboardUrl: 'URL del panel (opcional)',
+    notifySymbol: 'Símbolo (opcional)',
+    notifyTint: 'Color (opcional)',
+    notifyAppearance: 'Aspecto de la actividad en directo',
+    notifyMetrics: 'Métricas',
+    notifyMetricsHelp: 'El temporizador se muestra de forma predeterminada. Las métricas opcionales sustituyen la línea del nombre de archivo.',
+    notifyDeviceId: 'ID del dispositivo o grupo',
+    notifyToken: 'Token de Notify!',
+    notifyIconUrl: 'URL del icono (opcional)',
+    notifyGroupType: 'Hilo de notificaciones (opcional)',
+    notifyGroupTypeHelp: 'Déjalo vacío para agrupar las notificaciones por impresora. Un valor personalizado las agrupa en el mismo hilo.',
+    notifyLiveActivities: 'Actividades en directo (iOS)',
+    notifyLiveActivitiesHelp: 'Sigue las impresiones en la pantalla de bloqueo de iOS y en la Dynamic Island. Usa un ID de dispositivo iOS y añade otro proveedor para las notificaciones de grupo.',
+    notifyLifecycleHelp: 'Las actividades en directo siguen cada impresión automáticamente. Los interruptores de abajo solo controlan las notificaciones push. Las horas de silencio bloquean nuevas actividades; las existentes se siguen actualizando y finalizando. Los resúmenes diarios no retrasan las actividades.',
+    notifyIosFeaturesUnavailable: 'Las actividades en directo y los widgets de la pantalla de bloqueo requieren un ID de dispositivo iOS. Los ID de grupos, navegadores y macOS solo usan notificaciones push.',
+    notifyTestHelp: 'La prueba envía una notificación push. Una actividad en directo comienza al iniciar una impresión.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6597,6 +6626,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Notificaciones push y actividades en directo de iOS mediante Notify!',
       email: 'Notificaciones por correo SMTP',
       telegram: 'Notificaciones mediante un bot de Telegram',
       discord: 'Enviar a un canal de Discord mediante un webhook',

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

@@ -6562,8 +6562,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Widgets de l’écran verrouillé (iOS)',
+    notifyLockScreenWidgetsHelp: 'Seules les imprimantes actives hors mode maintenance reçoivent des widgets. Notify! autorise un total de 10 widgets par appareil. Ajoutez un widget Notify! via la personnalisation de l’écran verrouillé iOS et sélectionnez une imprimante. Il affiche l’état, la progression et les problèmes sans compte à rebours en direct. iOS actualise environ toutes les 15 minutes ou plus.',
+    notifyPhotoUnavailable: 'Les destinataires de navigateur et de groupe ne prennent en charge que les notifications textuelles ; les photos jointes ne sont pas disponibles.',
+    notifyShowStage: 'Afficher l’étape d’impression',
+    notifyTimeSensitive: 'Alertes de problème urgentes',
+    notifyTimeSensitiveHelp: 'Marque les impressions échouées ou arrêtées et les problèmes d’imprimante ou d’IA comme urgents. Les autres notifications restent normales.',
+    notifyPrivacy: 'Masquer le nom du fichier sur la vignette',
+    notifyPrivacyHelp: 'Masque les noms de fichiers uniquement dans l’activité en direct. Le texte des notifications utilise toujours les modèles de notification.',
+    notifyStyle: 'Style de progression',
+    notifyStyleBar: 'Barre de progression',
+    notifyStyleSegments: 'Segments de progression',
+    notifyStyleNone: 'Aucun indicateur de progression',
+    notifyDashboardUrl: 'URL du tableau de bord (facultatif)',
+    notifySymbol: 'Symbole (facultatif)',
+    notifyTint: 'Couleur (facultatif)',
+    notifyAppearance: 'Apparence de l’activité en direct',
+    notifyMetrics: 'Mesures',
+    notifyMetricsHelp: 'Le minuteur est affiché par défaut. Les mesures facultatives remplacent la ligne du nom de fichier.',
+    notifyDeviceId: 'Identifiant de l’appareil ou du groupe',
+    notifyToken: 'Jeton Notify!',
+    notifyIconUrl: 'URL de l’icône (facultatif)',
+    notifyGroupType: 'Fil de notifications (facultatif)',
+    notifyGroupTypeHelp: 'Laissez vide pour regrouper les notifications par imprimante. Une valeur personnalisée les regroupe dans le même fil.',
+    notifyLiveActivities: 'Activités en direct (iOS)',
+    notifyLiveActivitiesHelp: 'Suivez les impressions sur l’écran verrouillé iOS et dans la Dynamic Island. Utilisez un identifiant d’appareil iOS et ajoutez un fournisseur distinct pour les notifications de groupe.',
+    notifyLifecycleHelp: 'Les activités en direct suivent automatiquement chaque impression. Les interrupteurs ci-dessous ne contrôlent que les notifications push. Les heures silencieuses bloquent les nouvelles activités ; celles en cours continuent à se mettre à jour et à se terminer. Les résumés quotidiens ne retardent pas les activités.',
+    notifyIosFeaturesUnavailable: 'Les activités en direct et les widgets de l’écran verrouillé nécessitent un identifiant d’appareil iOS. Les identifiants de groupe, de navigateur et de macOS utilisent uniquement les notifications push.',
+    notifyTestHelp: 'Le test envoie une notification push. Une activité en direct commence au démarrage d’une impression.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6577,6 +6606,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Notifications push et activités en direct iOS via Notify!',
       email: 'Notifications par e-mail SMTP',
       telegram: 'Notifications via un bot Telegram',
       discord: 'Envoyer vers un canal Discord via webhook',

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

@@ -6561,8 +6561,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Widget della schermata di blocco (iOS)',
+    notifyLockScreenWidgetsHelp: 'Solo le stampanti attive al di fuori della modalità manutenzione ricevono widget. Notify! consente un totale di 10 widget per dispositivo. Aggiungi un widget Notify! dalla personalizzazione della schermata di blocco iOS e seleziona una stampante. Mostra stato, avanzamento e problemi senza conto alla rovescia in tempo reale. iOS si aggiorna circa ogni 15 minuti o più.',
+    notifyPhotoUnavailable: 'I destinatari browser e gruppo supportano solo notifiche di testo; gli allegati fotografici non sono disponibili.',
+    notifyShowStage: 'Mostra la fase di stampa',
+    notifyTimeSensitive: 'Avvisi urgenti per problemi',
+    notifyTimeSensitiveHelp: 'Segna come urgenti le stampe fallite o interrotte e i problemi della stampante o rilevati dall’IA. Le altre notifiche rimangono normali.',
+    notifyPrivacy: 'Nascondi il nome del file nel riquadro',
+    notifyPrivacyHelp: 'Nasconde i nomi dei file solo nell’attività in tempo reale. Il testo delle notifiche usa ancora i modelli di notifica.',
+    notifyStyle: 'Stile di avanzamento',
+    notifyStyleBar: 'Barra di avanzamento',
+    notifyStyleSegments: 'Segmenti',
+    notifyStyleNone: 'Nessun indicatore di avanzamento',
+    notifyDashboardUrl: 'URL della dashboard (facoltativo)',
+    notifySymbol: 'Simbolo (facoltativo)',
+    notifyTint: 'Colore (facoltativo)',
+    notifyAppearance: 'Aspetto dell’attività in tempo reale',
+    notifyMetrics: 'Metriche',
+    notifyMetricsHelp: 'Il timer è mostrato per impostazione predefinita. Le metriche facoltative sostituiscono la riga del nome del file.',
+    notifyDeviceId: 'ID dispositivo o gruppo',
+    notifyToken: 'Token di Notify!',
+    notifyIconUrl: 'URL dell’icona (facoltativo)',
+    notifyGroupType: 'Raggruppamento notifiche (facoltativo)',
+    notifyGroupTypeHelp: 'Lascia vuoto per raggruppare le notifiche per stampante. Un valore personalizzato le raggruppa nello stesso thread.',
+    notifyLiveActivities: 'Attività in tempo reale (iOS)',
+    notifyLiveActivitiesHelp: 'Segui le stampe sulla schermata di blocco iOS e nella Dynamic Island. Usa un ID dispositivo iOS e aggiungi un fornitore separato per le notifiche di gruppo.',
+    notifyLifecycleHelp: 'Le attività in tempo reale seguono automaticamente ogni stampa. Gli interruttori qui sotto controllano solo le notifiche push. Le ore silenziose bloccano le nuove attività; quelle esistenti continuano ad aggiornarsi e a terminare. I riepiloghi giornalieri non ritardano le attività.',
+    notifyIosFeaturesUnavailable: 'Le attività in tempo reale e i widget della schermata di blocco richiedono un ID dispositivo iOS. Gli ID di gruppi, browser e macOS usano solo notifiche push.',
+    notifyTestHelp: 'Il test invia una notifica push. Un’attività in tempo reale inizia all’avvio di una stampa.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6576,6 +6605,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Notifiche push e attività in tempo reale iOS tramite Notify!',
       email: 'Notifiche email tramite SMTP',
       telegram: 'Notifiche tramite bot Telegram',
       discord: 'Invia a un canale Discord tramite webhook',

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

@@ -6575,8 +6575,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'ロック画面ウィジェット(iOS)',
+    notifyLockScreenWidgetsHelp: 'メンテナンスモードではない有効なプリンターのみウィジェットの対象になります。Notify!ではデバイスごとに合計10個までウィジェットを作成できます。 iOSのロック画面のカスタマイズからNotify!ウィジェットを追加し、プリンターを選択してください。状態、進捗、問題を表示しますが、リアルタイムのカウントダウンはありません。iOSによる更新はおおむね15分間隔、またはそれ以上かかります。',
+    notifyPhotoUnavailable: 'ブラウザーとグループ宛てにはテキスト通知のみ送信できます。写真の添付は利用できません。',
+    notifyShowStage: '印刷段階を表示',
+    notifyTimeSensitive: '問題の即時通知',
+    notifyTimeSensitiveHelp: '失敗・停止した印刷やプリンター・AIの問題を即時通知として送信します。それ以外は通常のプッシュ通知です。',
+    notifyPrivacy: 'タイルでファイル名を非表示',
+    notifyPrivacyHelp: 'ライブアクティビティ内のファイル名のみを非表示にします。プッシュ通知の本文には引き続き通知テンプレートが使用されます。',
+    notifyStyle: '進捗の表示形式',
+    notifyStyleBar: '進捗バー',
+    notifyStyleSegments: 'セグメント',
+    notifyStyleNone: '進捗を表示しない',
+    notifyDashboardUrl: 'ダッシュボードのURL(任意)',
+    notifySymbol: 'シンボル(任意)',
+    notifyTint: '色(任意)',
+    notifyAppearance: 'ライブアクティビティの外観',
+    notifyMetrics: '表示する指標',
+    notifyMetricsHelp: '既定ではタイマーが表示されます。任意の指標を選択するとファイル名の行が置き換わります。',
+    notifyDeviceId: 'デバイスまたはグループのID',
+    notifyToken: 'Notify! のトークン',
+    notifyIconUrl: 'アイコンのURL(任意)',
+    notifyGroupType: '通知スレッド(任意)',
+    notifyGroupTypeHelp: '空欄の場合、プリンターごとにプッシュ通知をグループ化します。値を指定すると同じスレッドにまとめられます。',
+    notifyLiveActivities: 'ライブアクティビティ(iOS)',
+    notifyLiveActivitiesHelp: 'iOSのロック画面とDynamic Islandで印刷状況を確認できます。iOSデバイスのIDを使用してください。グループへの通知には別のプロバイダーを追加してください。',
+    notifyLifecycleHelp: 'ライブアクティビティは各印刷を自動的に追跡します。以下のイベントスイッチはプッシュ通知のみを制御します。通知休止時間中は新しいアクティビティを開始しませんが、既存のものは更新・終了されます。日次ダイジェストによってアクティビティが遅延することはありません。',
+    notifyIosFeaturesUnavailable: 'ライブアクティビティとロック画面ウィジェットにはiOSデバイスのIDが必要です。グループ、ブラウザー、macOSのIDではプッシュ通知のみ利用できます。',
+    notifyTestHelp: 'テストではプッシュ通知を送信します。ライブアクティビティは印刷開始時に開始されます。',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6590,6 +6619,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Notify! によるプッシュ通知とiOSライブアクティビティ',
       email: 'SMTPメール通知',
       telegram: 'Telegramボット経由の通知',
       discord: 'Webhook経由でDiscordチャンネルに送信',

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

@@ -6297,7 +6297,36 @@ export default {
     delayAfterDryingMinutes: '건조 후 지연 (분)'
   },
   notifications: {
+    notifyLockScreenWidgets: '잠금 화면 위젯 (iOS)',
+    notifyLockScreenWidgetsHelp: '유지보수 모드가 아닌 활성 프린터에만 위젯이 생성됩니다. Notify!는 기기당 총 10개의 위젯을 허용합니다. iOS 잠금 화면 사용자화에서 Notify! 위젯을 추가하고 프린터를 선택하세요. 실시간 카운트다운 없이 상태, 진행률 및 문제를 표시합니다. iOS는 약 15분 또는 그보다 긴 간격으로 새로 고칩니다.',
+    notifyPhotoUnavailable: '브라우저 및 그룹 수신자는 텍스트 알림만 지원하며 사진을 첨부할 수 없습니다.',
+    notifyShowStage: '출력 단계 표시',
+    notifyTimeSensitive: '문제 발생 시 긴급 알림',
+    notifyTimeSensitiveHelp: '실패하거나 중지된 출력 및 프린터 또는 AI 문제를 긴급 알림으로 표시합니다. 다른 푸시 알림은 일반 알림으로 유지됩니다.',
+    notifyPrivacy: '타일에서 파일 이름 숨기기',
+    notifyPrivacyHelp: '실시간 현황에서만 파일 이름을 숨깁니다. 푸시 알림 텍스트는 여전히 알림 템플릿을 사용합니다.',
+    notifyStyle: '진행률 표시 방식',
+    notifyStyleBar: '진행률 막대',
+    notifyStyleSegments: '구간 표시',
+    notifyStyleNone: '진행률 표시 안 함',
+    notifyDashboardUrl: '대시보드 URL (선택 사항)',
+    notifySymbol: '기호 (선택 사항)',
+    notifyTint: '색상 (선택 사항)',
+    notifyAppearance: '실시간 현황 모양',
+    notifyMetrics: '측정값',
+    notifyMetricsHelp: '기본적으로 타이머가 표시됩니다. 선택한 측정값이 파일 이름 줄을 대체합니다.',
+    notifyDeviceId: '기기 또는 그룹 ID',
+    notifyToken: 'Notify! 토큰',
+    notifyIconUrl: '아이콘 URL (선택 사항)',
+    notifyGroupType: '알림 스레드 (선택 사항)',
+    notifyGroupTypeHelp: '비워 두면 프린터별로 푸시 알림을 그룹화합니다. 사용자 지정 값을 입력하면 같은 스레드로 묶습니다.',
+    notifyLiveActivities: '실시간 현황 (iOS)',
+    notifyLiveActivitiesHelp: 'iOS 잠금 화면과 Dynamic Island에서 출력 상태를 확인하세요. iOS 기기 ID를 사용하고 그룹 알림용 공급자는 별도로 추가하세요.',
+    notifyLifecycleHelp: '실시간 현황은 각 출력을 자동으로 추적합니다. 아래 이벤트 스위치는 푸시 알림만 제어합니다. 방해 금지 시간에는 새 실시간 현황이 시작되지 않지만 기존 현황은 계속 업데이트되고 종료됩니다. 일일 요약으로 인해 실시간 현황이 지연되지는 않습니다.',
+    notifyIosFeaturesUnavailable: '실시간 현황 및 잠금 화면 위젯에는 iOS 기기 ID가 필요합니다. 그룹, 브라우저 및 macOS ID는 푸시 알림만 사용합니다.',
+    notifyTestHelp: '테스트는 푸시 알림을 보냅니다. 실시간 현황은 출력이 시작될 때 시작됩니다.',
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6310,6 +6339,7 @@ export default {
       gotify: 'Gotify'
     },
     providerDescriptions: {
+      notify: 'Notify!를 통한 푸시 알림 및 iOS 실시간 현황',
       email: 'SMTP 이메일 알림',
       telegram: 'Telegram 봇을 통한 알림',
       discord: 'Webhook을 통해 Discord 채널로 전송',

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

@@ -6625,8 +6625,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Toegangsschermwidgets (iOS)',
+    notifyLockScreenWidgetsHelp: 'Alleen actieve printers buiten de onderhoudsmodus krijgen widgets. Notify! staat in totaal 10 widgets per apparaat toe. Voeg een Notify!-widget toe via de aanpassing van het iOS-toegangsscherm en selecteer een printer. De widget toont status, voortgang en problemen zonder live afteltimer. iOS ververst ongeveer elke 15 minuten of minder vaak.',
+    notifyPhotoUnavailable: 'Browser- en groepsontvangers ondersteunen alleen tekstmeldingen; fotobijlagen zijn niet beschikbaar.',
+    notifyShowStage: 'Afdrukfase tonen',
+    notifyTimeSensitive: 'Tijdgevoelige probleemmeldingen',
+    notifyTimeSensitiveHelp: 'Markeer mislukte of gestopte afdrukken en printer- of AI-problemen als tijdgevoelig. Andere pushmeldingen blijven normaal.',
+    notifyPrivacy: 'Bestandsnaam op tegel verbergen',
+    notifyPrivacyHelp: 'Verbergt bestandsnamen alleen in de liveactiviteit. Pushteksten gebruiken nog steeds de meldingssjablonen.',
+    notifyStyle: 'Voortgangsstijl',
+    notifyStyleBar: 'Voortgangsbalk',
+    notifyStyleSegments: 'Segmenten',
+    notifyStyleNone: 'Geen voortgangsindicator',
+    notifyDashboardUrl: 'Dashboard-URL (optioneel)',
+    notifySymbol: 'Symbool (optioneel)',
+    notifyTint: 'Kleur (optioneel)',
+    notifyAppearance: 'Uiterlijk van liveactiviteit',
+    notifyMetrics: 'Meetwaarden',
+    notifyMetricsHelp: 'De timer wordt standaard getoond. Optionele meetwaarden vervangen de regel met de bestandsnaam.',
+    notifyDeviceId: 'Apparaat- of groeps-ID',
+    notifyToken: 'Notify!-token',
+    notifyIconUrl: 'Pictogram-URL (optioneel)',
+    notifyGroupType: 'Meldingengroep (optioneel)',
+    notifyGroupTypeHelp: 'Laat leeg om pushmeldingen per printer te groeperen. Een eigen waarde groepeert ze in dezelfde thread.',
+    notifyLiveActivities: 'Liveactiviteiten (iOS)',
+    notifyLiveActivitiesHelp: 'Volg afdrukken op het iOS-toegangsscherm en in de Dynamic Island. Gebruik een iOS-apparaat-ID en voeg een aparte aanbieder toe voor groepsmeldingen.',
+    notifyLifecycleHelp: 'Liveactiviteiten volgen elke afdruk automatisch. De schakelaars hieronder regelen alleen pushmeldingen. Stille uren blokkeren nieuwe activiteiten; bestaande activiteiten worden nog bijgewerkt en beëindigd. Dagoverzichten vertragen activiteiten niet.',
+    notifyIosFeaturesUnavailable: 'Liveactiviteiten en toegangsschermwidgets vereisen een iOS-apparaat-ID. Groeps-, browser- en macOS-ID’s gebruiken alleen pushmeldingen.',
+    notifyTestHelp: 'De test verstuurt een pushmelding. Een liveactiviteit begint wanneer een afdruk start.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6640,6 +6669,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Pushmeldingen en iOS-liveactiviteiten via Notify!',
       email: 'SMTP-e-mailmeldingen',
       telegram: 'Meldingen via Telegram-bot',
       discord: 'Versturen naar Discord-kanaal via webhook',

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

@@ -6561,8 +6561,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Widgets da tela bloqueada (iOS)',
+    notifyLockScreenWidgetsHelp: 'Apenas impressoras ativas fora do modo de manutenção recebem widgets. O Notify! permite um total de 10 widgets por dispositivo. Adicione um widget do Notify! pela personalização da tela bloqueada do iOS e selecione uma impressora. Ele mostra status, progresso e problemas sem contagem regressiva ao vivo. O iOS atualiza a cada 15 minutos ou mais, aproximadamente.',
+    notifyPhotoUnavailable: 'Destinatários de navegador e grupo aceitam apenas notificações de texto; anexos de fotos não estão disponíveis.',
+    notifyShowStage: 'Mostrar etapa da impressão',
+    notifyTimeSensitive: 'Alertas urgentes de problemas',
+    notifyTimeSensitiveHelp: 'Marca impressões com falha ou interrompidas e problemas da impressora ou detectados por IA como urgentes. As outras notificações permanecem normais.',
+    notifyPrivacy: 'Ocultar nome do arquivo no bloco',
+    notifyPrivacyHelp: 'Oculta nomes de arquivos apenas na Atividade ao Vivo. O texto das notificações ainda usa os modelos de notificação.',
+    notifyStyle: 'Estilo do progresso',
+    notifyStyleBar: 'Barra de progresso',
+    notifyStyleSegments: 'Segmentos',
+    notifyStyleNone: 'Sem indicador de progresso',
+    notifyDashboardUrl: 'URL do painel (opcional)',
+    notifySymbol: 'Símbolo (opcional)',
+    notifyTint: 'Cor (opcional)',
+    notifyAppearance: 'Aparência da Atividade ao Vivo',
+    notifyMetrics: 'Métricas',
+    notifyMetricsHelp: 'O temporizador é exibido por padrão. As métricas opcionais substituem a linha do nome do arquivo.',
+    notifyDeviceId: 'ID do dispositivo ou grupo',
+    notifyToken: 'Token do Notify!',
+    notifyIconUrl: 'URL do ícone (opcional)',
+    notifyGroupType: 'Grupo de notificações (opcional)',
+    notifyGroupTypeHelp: 'Deixe em branco para agrupar notificações por impressora. Um valor personalizado as agrupa na mesma conversa.',
+    notifyLiveActivities: 'Atividades ao Vivo (iOS)',
+    notifyLiveActivitiesHelp: 'Acompanhe impressões na tela bloqueada do iOS e na Dynamic Island. Use um ID de dispositivo iOS e adicione um provedor separado para notificações de grupo.',
+    notifyLifecycleHelp: 'As Atividades ao Vivo acompanham cada impressão automaticamente. Os controles abaixo afetam apenas as notificações push. O horário silencioso bloqueia novas atividades; as existentes continuam a ser atualizadas e encerradas. Os resumos diários não atrasam as atividades.',
+    notifyIosFeaturesUnavailable: 'As Atividades ao Vivo e os widgets da tela bloqueada exigem um ID de dispositivo iOS. IDs de grupos, navegadores e macOS usam apenas notificações push.',
+    notifyTestHelp: 'O teste envia uma notificação push. Uma Atividade ao Vivo começa quando uma impressão é iniciada.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6576,6 +6605,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Notificações push e Atividades ao Vivo do iOS pelo Notify!',
       email: 'Notificações por e-mail SMTP',
       telegram: 'Notificações via bot do Telegram',
       discord: 'Enviar para canal do Discord via webhook',

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

@@ -6284,7 +6284,36 @@ export default {
     enableSwitchbarHint: "Включите «Показывать на панели переключателей» в разделе «Настройки → Умные розетки»",
   },
   notifications: {
+    notifyLockScreenWidgets: 'Виджеты экрана блокировки (iOS)',
+    notifyLockScreenWidgetsHelp: 'Виджеты создаются только для активных принтеров, не находящихся в режиме обслуживания. Notify! допускает не более 10 виджетов на устройство суммарно. Добавьте виджет Notify! через настройку экрана блокировки iOS и выберите принтер. Он показывает состояние, прогресс и проблемы без обратного отсчёта в реальном времени. iOS обновляет данные примерно раз в 15 минут или реже.',
+    notifyPhotoUnavailable: 'Получатели в браузере и группы поддерживают только текстовые уведомления; вложения с фотографиями недоступны.',
+    notifyShowStage: 'Показывать этап печати',
+    notifyTimeSensitive: 'Срочные уведомления о проблемах',
+    notifyTimeSensitiveHelp: 'Помечать неудачные или остановленные печати и проблемы принтера или ИИ как срочные. Остальные push-уведомления остаются обычными.',
+    notifyPrivacy: 'Скрыть имя файла на плитке',
+    notifyPrivacyHelp: 'Скрывает имена файлов только в эфире активности. Текст push-уведомлений по-прежнему использует шаблоны уведомлений.',
+    notifyStyle: 'Вид индикатора прогресса',
+    notifyStyleBar: 'Полоса прогресса',
+    notifyStyleSegments: 'Сегменты',
+    notifyStyleNone: 'Без индикатора прогресса',
+    notifyDashboardUrl: 'URL панели управления (необязательно)',
+    notifySymbol: 'Символ (необязательно)',
+    notifyTint: 'Цвет (необязательно)',
+    notifyAppearance: 'Оформление эфира активности',
+    notifyMetrics: 'Показатели',
+    notifyMetricsHelp: 'Таймер отображается по умолчанию. Дополнительные показатели заменяют строку с именем файла.',
+    notifyDeviceId: 'ID устройства или группы',
+    notifyToken: 'Токен Notify!',
+    notifyIconUrl: 'URL значка (необязательно)',
+    notifyGroupType: 'Группа уведомлений (необязательно)',
+    notifyGroupTypeHelp: 'Оставьте пустым для группировки push-уведомлений по принтеру. Собственное значение объединяет их в одну группу.',
+    notifyLiveActivities: 'Эфир активности (iOS)',
+    notifyLiveActivitiesHelp: 'Следите за печатью на экране блокировки iOS и в Dynamic Island. Используйте ID устройства iOS; для групповых уведомлений добавьте отдельного провайдера.',
+    notifyLifecycleHelp: 'Эфир активности автоматически отслеживает каждую печать. Переключатели ниже управляют только push-уведомлениями. Тихие часы блокируют новые активности; существующие продолжают обновляться и завершаться. Ежедневные сводки не задерживают активности.',
+    notifyIosFeaturesUnavailable: 'Для эфира активности и виджетов экрана блокировки нужен ID устройства iOS. ID групп, браузеров и macOS используют только push-уведомления.',
+    notifyTestHelp: 'Тест отправляет push-уведомление. Эфир активности запускается при начале печати.',
     providerTypes: {
+      notify: 'Notify!',
       callmebot: "CallMeBot/WhatsApp",
       ntfy: "ntfy",
       pushover: "Pushover",
@@ -6297,6 +6326,7 @@ export default {
       gotify: "Gotify",
     },
     providerDescriptions: {
+      notify: 'Push-уведомления и Live Activities для iOS через Notify!',
       email: "Уведомления по электронной почте через SMTP",
       telegram: "Уведомления через Telegram-бота",
       discord: "Отправка в канал Discord через вебхук",

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

@@ -6623,8 +6623,37 @@ errors: {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Låsskärmswidgetar (iOS)',
+    notifyLockScreenWidgetsHelp: 'Endast aktiva skrivare utanför underhållsläget får widgetar. Notify! tillåter totalt 10 widgetar per enhet. Lägg till en Notify!-widget när du anpassar låsskärmen i iOS och välj en skrivare. Den visar status, förlopp och problem utan en nedräkning i realtid. iOS uppdaterar ungefär var 15:e minut eller mer sällan.',
+    notifyPhotoUnavailable: 'Webbläsar- och gruppmottagare stöder endast textnotiser; fotobilagor är inte tillgängliga.',
+    notifyShowStage: 'Visa utskriftsfas',
+    notifyTimeSensitive: 'Tidskänsliga problemvarningar',
+    notifyTimeSensitiveHelp: 'Markera misslyckade eller stoppade utskrifter och skrivar- eller AI-problem som tidskänsliga. Andra pushnotiser förblir normala.',
+    notifyPrivacy: 'Dölj filnamnet på rutan',
+    notifyPrivacyHelp: 'Döljer filnamn endast i liveaktiviteten. Pushtexter använder fortfarande notismallarna.',
+    notifyStyle: 'Förloppsstil',
+    notifyStyleBar: 'Förloppsindikator',
+    notifyStyleSegments: 'Segment',
+    notifyStyleNone: 'Ingen förloppsindikator',
+    notifyDashboardUrl: 'URL till kontrollpanelen (valfritt)',
+    notifySymbol: 'Symbol (valfritt)',
+    notifyTint: 'Färg (valfritt)',
+    notifyAppearance: 'Liveaktivitetens utseende',
+    notifyMetrics: 'Mätvärden',
+    notifyMetricsHelp: 'Timern visas som standard. Valfria mätvärden ersätter filnamnsraden.',
+    notifyDeviceId: 'Enhets- eller grupp-ID',
+    notifyToken: 'Notify!-token',
+    notifyIconUrl: 'Ikon-URL (valfritt)',
+    notifyGroupType: 'Notisgrupp (valfritt)',
+    notifyGroupTypeHelp: 'Lämna tomt för att gruppera pushnotiser per skrivare. Ett eget värde grupperar dem i samma tråd.',
+    notifyLiveActivities: 'Liveaktiviteter (iOS)',
+    notifyLiveActivitiesHelp: 'Följ utskrifter på låsskärmen i iOS och i Dynamic Island. Använd ett iOS-enhets-ID och lägg till en separat leverantör för gruppnotiser.',
+    notifyLifecycleHelp: 'Liveaktiviteter följer varje utskrift automatiskt. Reglagen nedan styr bara pushnotiser. Tysta timmar blockerar nya aktiviteter; befintliga aktiviteter uppdateras och avslutas fortfarande. Dagliga sammanfattningar fördröjer inte aktiviteter.',
+    notifyIosFeaturesUnavailable: 'Liveaktiviteter och låsskärmswidgetar kräver ett iOS-enhets-ID. Grupp-, webbläsar- och macOS-ID:n använder endast pushnotiser.',
+    notifyTestHelp: 'Testet skickar en pushnotis. En liveaktivitet börjar när en utskrift startar.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6638,6 +6667,7 @@ errors: {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Pushnotiser och liveaktiviteter för iOS via Notify!',
       email: 'SMTP e-postnotiser',
       telegram: 'Notiser via Telegram-bot',
       discord: 'Skicka till Discord-kanal via webhook',

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

@@ -6534,7 +6534,36 @@ export default {
 
   // Bildirimler
   notifications: {
+    notifyLockScreenWidgets: 'Kilit Ekranı Araç Takımları (iOS)',
+    notifyLockScreenWidgetsHelp: 'Yalnızca bakım modu dışındaki etkin yazıcılar için araç takımı oluşturulur. Notify! cihaz başına toplam 10 araç takımına izin verir. iOS Kilit Ekranı özelleştirmesinden bir Notify! araç takımı ekleyip yazıcı seçin. Canlı geri sayım olmadan durum, ilerleme ve sorunları gösterir. iOS yaklaşık 15 dakikada bir veya daha seyrek yeniler.',
+    notifyPhotoUnavailable: 'Tarayıcı ve grup alıcıları yalnızca metin bildirimlerini destekler; fotoğraf ekleri kullanılamaz.',
+    notifyShowStage: 'Baskı aşamasını göster',
+    notifyTimeSensitive: 'Zamana duyarlı sorun uyarıları',
+    notifyTimeSensitiveHelp: 'Başarısız veya durdurulan baskıları ve yazıcı ya da yapay zekâ sorunlarını zamana duyarlı olarak işaretler. Diğer anlık bildirimler normal kalır.',
+    notifyPrivacy: 'Kutucukta dosya adını gizle',
+    notifyPrivacyHelp: 'Dosya adlarını yalnızca Canlı Etkinlikte gizler. Anlık bildirim metni bildirim şablonlarını kullanmaya devam eder.',
+    notifyStyle: 'İlerleme stili',
+    notifyStyleBar: 'İlerleme çubuğu',
+    notifyStyleSegments: 'Bölümler',
+    notifyStyleNone: 'İlerleme göstergesi yok',
+    notifyDashboardUrl: 'Pano URL’si (isteğe bağlı)',
+    notifySymbol: 'Sembol (isteğe bağlı)',
+    notifyTint: 'Renk (isteğe bağlı)',
+    notifyAppearance: 'Canlı Etkinlik görünümü',
+    notifyMetrics: 'Ölçümler',
+    notifyMetricsHelp: 'Zamanlayıcı varsayılan olarak gösterilir. İsteğe bağlı ölçümler dosya adı satırının yerini alır.',
+    notifyDeviceId: 'Cihaz veya grup kimliği',
+    notifyToken: 'Notify! belirteci',
+    notifyIconUrl: 'Simge URL’si (isteğe bağlı)',
+    notifyGroupType: 'Bildirim grubu (isteğe bağlı)',
+    notifyGroupTypeHelp: 'Anlık bildirimleri yazıcıya göre gruplamak için boş bırakın. Özel bir değer bunları aynı grupta toplar.',
+    notifyLiveActivities: 'Canlı Etkinlikler (iOS)',
+    notifyLiveActivitiesHelp: 'Baskıları iOS Kilit Ekranında ve Dynamic Island’da takip edin. Bir iOS cihaz kimliği kullanın; grup bildirimleri için ayrı bir sağlayıcı ekleyin.',
+    notifyLifecycleHelp: 'Canlı Etkinlikler her baskıyı otomatik olarak takip eder. Aşağıdaki olay anahtarları yalnızca anlık bildirimleri kontrol eder. Sessiz saatler yeni etkinlikleri engeller; mevcut etkinlikler güncellenmeye ve sonlandırılmaya devam eder. Günlük özetler etkinlikleri geciktirmez.',
+    notifyIosFeaturesUnavailable: 'Canlı Etkinlikler ve Kilit Ekranı araç takımları için bir iOS cihaz kimliği gerekir. Grup, tarayıcı ve macOS kimlikleri yalnızca anlık bildirimleri kullanır.',
+    notifyTestHelp: 'Test bir anlık bildirim gönderir. Canlı Etkinlik, baskı başladığında başlar.',
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6547,6 +6576,7 @@ export default {
       gotify: 'Gotify',
     },
     providerDescriptions: {
+      notify: 'Notify! üzerinden anlık bildirimler ve iOS Canlı Etkinlikleri',
       email: 'SMTP e-posta bildirimleri',
       telegram: 'Telegram botu üzerinden bildirimler',
       discord: 'Webhook üzerinden Discord kanalına gönder',

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

@@ -6615,8 +6615,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: 'Віджети замкненого екрана (iOS)',
+    notifyLockScreenWidgetsHelp: 'Віджети створюються лише для активних принтерів поза режимом обслуговування. Notify! дозволяє загалом до 10 віджетів на пристрій. Додайте віджет Notify! у налаштуваннях замкненого екрана iOS і виберіть принтер. Він показує стан, прогрес і проблеми без зворотного відліку в реальному часі. iOS оновлює дані приблизно раз на 15 хвилин або рідше.',
+    notifyPhotoUnavailable: 'Одержувачі в браузері та групи підтримують лише текстові сповіщення; вкладення з фотографіями недоступні.',
+    notifyShowStage: 'Показувати етап друку',
+    notifyTimeSensitive: 'Термінові сповіщення про проблеми',
+    notifyTimeSensitiveHelp: 'Позначати невдалі або зупинені друки та проблеми принтера чи ШІ як термінові. Інші push-сповіщення залишаються звичайними.',
+    notifyPrivacy: 'Приховати назву файлу на плитці',
+    notifyPrivacyHelp: 'Приховує назви файлів лише в живій дії. Текст push-сповіщень і далі використовує шаблони сповіщень.',
+    notifyStyle: 'Вигляд індикатора прогресу',
+    notifyStyleBar: 'Смуга прогресу',
+    notifyStyleSegments: 'Сегменти',
+    notifyStyleNone: 'Без індикатора прогресу',
+    notifyDashboardUrl: 'URL панелі керування (необов’язково)',
+    notifySymbol: 'Символ (необов’язково)',
+    notifyTint: 'Колір (необов’язково)',
+    notifyAppearance: 'Вигляд живої дії',
+    notifyMetrics: 'Показники',
+    notifyMetricsHelp: 'Таймер відображається за замовчуванням. Додаткові показники замінюють рядок із назвою файлу.',
+    notifyDeviceId: 'ID пристрою або групи',
+    notifyToken: 'Токен Notify!',
+    notifyIconUrl: 'URL значка (необов’язково)',
+    notifyGroupType: 'Група сповіщень (необов’язково)',
+    notifyGroupTypeHelp: 'Залиште порожнім, щоб групувати push-сповіщення за принтером. Власне значення об’єднує їх в одну групу.',
+    notifyLiveActivities: 'Живі дії (iOS)',
+    notifyLiveActivitiesHelp: 'Стежте за друком на замкненому екрані iOS та в Dynamic Island. Використовуйте ID пристрою iOS; для групових сповіщень додайте окремого постачальника.',
+    notifyLifecycleHelp: 'Живі дії автоматично відстежують кожен друк. Перемикачі нижче керують лише push-сповіщеннями. Тихі години блокують нові дії; наявні продовжують оновлюватися й завершуватися. Щоденні зведення не затримують дії.',
+    notifyIosFeaturesUnavailable: 'Для живих дій і віджетів замкненого екрана потрібен ID пристрою iOS. ID груп, браузерів і macOS використовують лише push-сповіщення.',
+    notifyTestHelp: 'Тест надсилає push-сповіщення. Жива дія починається на початку друку.',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: "CallMeBot/WhatsApp",
       ntfy: "ntfy",
       pushover: "Pushover",
@@ -6630,6 +6659,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: 'Push-сповіщення та Live Activities для iOS через Notify!',
       email: "Сповіщення електронною поштою SMTP",
       telegram: "Сповіщення через бота Telegram.",
       discord: "Надіслати на канал Discord через webhook",

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

@@ -6560,8 +6560,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: '锁定屏幕小组件(iOS)',
+    notifyLockScreenWidgetsHelp: '仅为未处于维护模式的启用打印机创建小组件。Notify! 每台设备总共允许最多 10 个小组件。 从 iOS 自定义锁定屏幕添加 Notify! 小组件并选择打印机。它显示状态、进度和问题,不提供实时倒计时。iOS 大约每 15 分钟或更长时间刷新一次。',
+    notifyPhotoUnavailable: '浏览器和群组收件人仅支持文本通知,无法附加照片。',
+    notifyShowStage: '显示打印阶段',
+    notifyTimeSensitive: '时效性问题警报',
+    notifyTimeSensitiveHelp: '将失败或停止的打印以及打印机或 AI 问题标记为时效性通知。其他推送保持普通级别。',
+    notifyPrivacy: '在卡片上隐藏文件名',
+    notifyPrivacyHelp: '仅在实时活动中隐藏文件名。推送文本仍使用通知模板。',
+    notifyStyle: '进度样式',
+    notifyStyleBar: '进度条',
+    notifyStyleSegments: '分段',
+    notifyStyleNone: '不显示进度',
+    notifyDashboardUrl: '仪表板 URL(可选)',
+    notifySymbol: '符号(可选)',
+    notifyTint: '颜色(可选)',
+    notifyAppearance: '实时活动外观',
+    notifyMetrics: '指标',
+    notifyMetricsHelp: '默认显示计时器。可选指标会替代文件名行。',
+    notifyDeviceId: '设备或群组 ID',
+    notifyToken: 'Notify! 令牌',
+    notifyIconUrl: '图标 URL(可选)',
+    notifyGroupType: '通知分组(可选)',
+    notifyGroupTypeHelp: '留空可按打印机分组推送通知。自定义值会将它们归入同一通知组。',
+    notifyLiveActivities: '实时活动(iOS)',
+    notifyLiveActivitiesHelp: '在 iOS 锁定屏幕和灵动岛上跟踪打印。请使用 iOS 设备 ID;如需群组推送,请添加单独的提供商。',
+    notifyLifecycleHelp: '实时活动会自动跟踪每次打印。下方的事件开关仅控制推送通知。免打扰时段会阻止新活动,但现有活动仍会更新和结束。每日摘要不会延迟活动。',
+    notifyIosFeaturesUnavailable: '实时活动和锁定屏幕小组件需要 iOS 设备 ID。群组、浏览器和 macOS ID 仅使用推送通知。',
+    notifyTestHelp: '测试会发送推送通知。实时活动会在打印开始时启动。',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6575,6 +6604,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: '通过 Notify! 发送推送通知和 iOS 实时活动',
       email: 'SMTP 电子邮件通知',
       telegram: '通过 Telegram 机器人发送通知',
       discord: '通过 Webhook 发送到 Discord 频道',

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

@@ -6560,8 +6560,37 @@ export default {
 
   // Notifications
   notifications: {
+    notifyLockScreenWidgets: '鎖定畫面小工具(iOS)',
+    notifyLockScreenWidgetsHelp: '僅為未處於維護模式的啟用印表機建立小工具。Notify! 每台裝置總共允許最多 10 個小工具。 從 iOS 自訂鎖定畫面新增 Notify! 小工具並選擇印表機。它顯示狀態、進度和問題,不提供即時倒數計時。iOS 大約每 15 分鐘或更久更新一次。',
+    notifyPhotoUnavailable: '瀏覽器和群組收件者僅支援文字通知,無法附加照片。',
+    notifyShowStage: '顯示列印階段',
+    notifyTimeSensitive: '即時問題警示',
+    notifyTimeSensitiveHelp: '將失敗或停止的列印以及印表機或 AI 問題標記為即時通知。其他推播維持一般等級。',
+    notifyPrivacy: '在卡片上隱藏檔案名稱',
+    notifyPrivacyHelp: '僅在即時動態中隱藏檔案名稱。推播文字仍使用通知範本。',
+    notifyStyle: '進度樣式',
+    notifyStyleBar: '進度列',
+    notifyStyleSegments: '分段',
+    notifyStyleNone: '不顯示進度',
+    notifyDashboardUrl: '儀表板 URL(選填)',
+    notifySymbol: '符號(選填)',
+    notifyTint: '顏色(選填)',
+    notifyAppearance: '即時動態外觀',
+    notifyMetrics: '指標',
+    notifyMetricsHelp: '預設顯示計時器。選填指標會取代檔案名稱列。',
+    notifyDeviceId: '裝置或群組 ID',
+    notifyToken: 'Notify! 權杖',
+    notifyIconUrl: '圖示 URL(選填)',
+    notifyGroupType: '通知群組(選填)',
+    notifyGroupTypeHelp: '留空可按印表機將推播通知分組。自訂值會將它們歸入同一通知群組。',
+    notifyLiveActivities: '即時動態(iOS)',
+    notifyLiveActivitiesHelp: '在 iOS 鎖定畫面和動態島上追蹤列印。請使用 iOS 裝置 ID;如需群組推播,請新增獨立的供應商。',
+    notifyLifecycleHelp: '即時動態會自動追蹤每次列印。下方的事件開關僅控制推播通知。勿擾時段會阻止新動態,但現有動態仍會更新和結束。每日摘要不會延遲動態。',
+    notifyIosFeaturesUnavailable: '即時動態和鎖定畫面小工具需要 iOS 裝置 ID。群組、瀏覽器和 macOS ID 僅使用推播通知。',
+    notifyTestHelp: '測試會傳送推播通知。即時動態會在列印開始時啟動。',
     // Provider types
     providerTypes: {
+      notify: 'Notify!',
       callmebot: 'CallMeBot/WhatsApp',
       ntfy: 'ntfy',
       pushover: 'Pushover',
@@ -6575,6 +6604,7 @@ export default {
     },
     // Provider descriptions
     providerDescriptions: {
+      notify: '透過 Notify! 傳送推播通知和 iOS 即時動態',
       email: 'SMTP 電子郵件通知',
       telegram: '透過 Telegram 機器人傳送通知',
       discord: '透過 Webhook 傳送到 Discord 頻道',

+ 3 - 0
frontend/src/utils/notify.ts

@@ -0,0 +1,3 @@
+// Notify! target prefixes identify capabilities that are unavailable to some recipients.
+export const isNotifyPushOnlyTarget = (deviceId: string) => /^(GRP|WB|MC)/i.test(deviceId.trim());
+export const isNotifyPhotoUnsupported = (deviceId: string) => /^(GRP|WB)/i.test(deviceId.trim());

File diff suppressed because it is too large
+ 0 - 0
static/assets/AdditionalSection-9uxxm13d.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AmsUnitCard-CeSWF1rR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-BrcJqnr-.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AreaChart-DJpIJXxF.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/AssignToAmsModal-dWQ_DHhy.js


+ 0 - 1
static/assets/Button-DDr6eAFy.js

@@ -1 +0,0 @@
-import{C as e}from"./client-DnySrlFv.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CamWallPage-BDGEzKQE.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraDiagnoseModal-BwHHipUP.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraPage-D1WovNYe.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CameraWall-D-W28jK-.js


+ 0 - 1
static/assets/Card-Bzc64K35.js

@@ -1 +0,0 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-DnySrlFv.js";var r=e(n(),1),i=t(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/CartesianChart-BAcHzPFb.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ColumnConfigModal-BBpKz9m6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConfigureAmsSlotModal-BgpDvjjX.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ConnectAuthorizePage-CIbOgIV8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ContextMenu-CwroFwVJ.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/CopyButton-C5In5weo.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ErrorBarContext-CcijMMkA.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ExternalLinkPage-BN4q7Gs-.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-BASV0fq8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FileUploadModal-Dee1GxH_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/FinancePage-D-k9CqTS.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GCodeViewerPage-BZpj3rht.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GcodeToolpathViewer-Cg6UnTDw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/GroupEditPage-CMQ5Sk5q.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-NAGAMcx7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/InventoryPage-o24Ni0S2.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LibraryTrashPage-zY9GRWm9.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-DXBBHPDD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/MaintenancePage-B6wtSJnR.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ModelSourcesPage-CI9pVpjC.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-DTBdRJe1.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/OrbitControls-Bbqf07B-.js


+ 0 - 1
static/assets/OverlayFrame-fLmufrTC.js

@@ -1 +0,0 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n,a as r}from"./client-DnySrlFv.js";var i=e(n(),1);function a(e,t=null,n=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return r.getOverlayLogo(t,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:t,revision:n})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,t,n]),e&&a?.token===t&&a.revision===n?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=t();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-gJCfcyUW.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PhotoGalleryModal-BPFizk3N.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrinterLocationsPage-Dj61FxW8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/PrintersPage-CLG7I78P.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProfilesPage-FzXKlAbc.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-Cg8wQL8j.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectsPage-CBeO6wA6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueryClientProvider-Ce8_sN3w.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueuePage-NR_JuCMy.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ReferenceLine-clJprXoH.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/RichTextEditor-CsfJOD7X.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-DCvPzv7P.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SetupPage-D-ymjFl3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SliceModal-YlJTMEj6.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyAmsPage-BzUFeU61.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyCalibrationPage-C9Vz9h1f.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyDashboard-0Mnwe1xw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-FFmQ5AMP.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/SpoolBuddySettingsPage-dHNHNIW3.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SpoolBuddyWriteTagPage-CQovcCoW.js


+ 0 - 1
static/assets/SpoolIcon-DE8TbEec.js

@@ -1 +0,0 @@
-import{i as e}from"./chunk-aKtaBQYM.js";import{C as t,D as n}from"./client-DnySrlFv.js";import{n as r}from"./filamentSwatchHelpers-D3oXQSGk.js";import{t as i}from"./spoolPaint-CrvpHqdD.js";var a=e(n(),1),o=t();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/SpreadsheetPreviewModal-KGR-XjV_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StatsPage-E-fXkkLD.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-CA9KpVK8.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/StreamOverlayPage-H9AqKpdI.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/SystemInfoPage-J8sIDAyN.js


Some files were not shown because too many files changed in this diff