Browse Source

Add first layer complete notification with camera snapshot (#679)

  Notify users when the first layer finishes printing so they can check
  adhesion remotely. Triggers once per print when layer 2 begins
  (layer_num >= 2, capped at <= 5 to handle reconnects). Includes a
  camera snapshot attachment. Adds the on_first_layer_complete toggle
  to all notification providers, with backend/frontend/i18n support
  across all 7 locales.
maziggy 2 months ago
parent
commit
0b6facc91c

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.2b3] - 2026-03-12
 
 ### New Features
+- **First Layer Complete Notification** ([#679](https://github.com/maziggy/bambuddy/issues/679)) — Get notified with a camera snapshot when the first layer finishes printing, so you can check adhesion remotely without watching the whole print. Enable the "First Layer Complete" toggle on any notification provider. Fires once per print when layer 2 begins (confirming layer 1 is done), with a guard against spurious triggers on printer reconnect. Requested by community.
 - **Remote AMS Drying** ([#292](https://github.com/maziggy/bambuddy/issues/292)) — Start, monitor, and stop drying sessions for AMS 2 Pro and AMS-HT directly from the Printers page. A flame icon appears on supported AMS cards; clicking it opens a popover to select filament type (PLA, PETG, TPU, ABS, ASA, PA, PC, PVA) with official BambuStudio temperature/duration presets, or set temperature manually. When drying is active, a status bar shows the time remaining with a live countdown and stop button. Supported on X1/X1C (fw 01.09+), P1P/P1S (fw 01.08+), H2D (fw 01.02.30+), H2D Pro, and X1E. Not supported on P2S, A1, A1 Mini, H2S, or H2C. Requires `printers:control` permission when authentication is enabled.
 - **Home Assistant Notification Provider** ([#656](https://github.com/maziggy/bambuddy/issues/656)) — Added Home Assistant as a notification provider. When HA is configured in Settings → Network → Home Assistant, selecting "Home Assistant" as a notification provider sends persistent notifications to the HA dashboard — no additional configuration needed. From there, HA automations can forward notifications to mobile apps, WhatsApp, or any other service. Requested by @TravisWilder.
 - **Virtual Printer Queue Auto-Dispatch Toggle** ([#587](https://github.com/maziggy/bambuddy/issues/587)) — Added an "Auto-dispatch" toggle to virtual printers in Queue mode. When enabled (default), prints sent from the slicer are added to the queue and start automatically on the assigned printer — matching the current behavior. When disabled, prints are added to the queue with `manual_start` set, so they wait for manual dispatch. This allows users who want to review and manually assign prints before they start. Requested by @Percy2Live.

+ 1 - 0
README.md

@@ -152,6 +152,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Filament usage and progress in failed/cancelled print notifications
 - HMS error alerts (AMS, nozzle, etc.)
 - Build plate detection alerts
+- First layer complete alert (with camera snapshot)
 - Bed cooled alerts (configurable threshold)
 - Queue events (waiting, skipped, failed)
 

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

@@ -58,6 +58,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_plate_not_empty": provider.on_plate_not_empty,
         # Bed cooled
         "on_bed_cooled": provider.on_bed_cooled,
+        # First layer complete
+        "on_first_layer_complete": provider.on_first_layer_complete,
         # Print queue events
         "on_queue_job_added": provider.on_queue_job_added,
         "on_queue_job_assigned": provider.on_queue_job_assigned,
@@ -135,6 +137,8 @@ async def create_notification_provider(
         on_plate_not_empty=provider_data.on_plate_not_empty,
         # Bed cooled
         on_bed_cooled=provider_data.on_bed_cooled,
+        # First layer complete
+        on_first_layer_complete=provider_data.on_first_layer_complete,
         # Print queue events
         on_queue_job_added=provider_data.on_queue_job_added,
         on_queue_job_assigned=provider_data.on_queue_job_assigned,

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

@@ -1226,6 +1226,14 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add first layer complete notification column to notification_providers
+    try:
+        await conn.execute(
+            text("ALTER TABLE notification_providers ADD COLUMN on_first_layer_complete BOOLEAN DEFAULT 0")
+        )
+    except OperationalError:
+        pass  # Already applied
+
     # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
     try:
         await conn.execute(text("ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0"))

+ 31 - 1
backend/app/main.py

@@ -262,6 +262,9 @@ _print_ams_mappings: dict[int, list[int]] = {}
 # Milestones are 25, 50, 75. Value of 0 means no milestone notified yet for current print.
 _last_progress_milestone: dict[int, int] = {}
 
+# Track whether first layer complete notification has been sent for current print
+_first_layer_notified: dict[int, bool] = {}
+
 # Track HMS errors that have been notified: {printer_id: set of error codes}
 # This prevents sending duplicate notifications for the same error
 _notified_hms_errors: dict[int, set[str]] = {}
@@ -432,6 +435,7 @@ async def on_printer_status_change(printer_id: int, state: PrinterState):
     elif progress < 5:
         # Reset milestone tracking when print restarts or new print begins
         _last_progress_milestone[printer_id] = 0
+        _first_layer_notified[printer_id] = False
 
     # HMS error codes that should not trigger notifications.
     # These are infrastructure/auth issues, not actionable print errors.
@@ -3310,11 +3314,37 @@ async def lifespan(app: FastAPI):
 
     # Layer change callback for external camera timelapse
     async def on_layer_change(printer_id: int, layer_num: int):
-        """Capture timelapse frame on layer change."""
+        """Capture timelapse frame on layer change + first layer notification."""
         from backend.app.services.layer_timelapse import on_layer_change as tl_layer_change
 
         await tl_layer_change(printer_id, layer_num)
 
+        # First layer complete notification (layer_num >= 2 means layer 1 is done)
+        if 2 <= layer_num <= 5 and not _first_layer_notified.get(printer_id, False):
+            _first_layer_notified[printer_id] = True
+            try:
+                async with async_session() as db:
+                    from backend.app.models.printer import Printer
+
+                    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+                    printer = result.scalar_one_or_none()
+                    if not printer:
+                        return
+                    printer_name = printer.name
+                    client = printer_manager.get_client(printer_id)
+                    state = client.state if client else None
+                    filename = (state.subtask_name or state.gcode_file or "Unknown") if state else "Unknown"
+                    total_layers = state.total_layers if state else 0
+
+                    image_data = await _capture_snapshot_for_notification(
+                        printer_id, printer, logging.getLogger(__name__)
+                    )
+                    await notification_service.on_first_layer_complete(
+                        printer_id, printer_name, filename, total_layers, db, image_data=image_data
+                    )
+            except Exception as e:
+                logging.getLogger(__name__).warning("First layer notification failed: %s", e)
+
     printer_manager.set_layer_change_callback(on_layer_change)
 
     # Initialize MQTT relay from settings

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

@@ -85,6 +85,7 @@ class NotificationProvider(Base):
 
     # Event triggers - Bed cooled after print
     on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
+    on_first_layer_complete = Column(Boolean, default=False)  # First layer finished printing
 
     # Event triggers - Print queue
     on_queue_job_added = Column(Boolean, default=False)  # Job added to queue

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

@@ -103,6 +103,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "Bed Cooled",
         "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
     },
+    {
+        "event_type": "first_layer_complete",
+        "name": "First Layer Complete",
+        "title_template": "First Layer Complete",
+        "body_template": "{printer}: {filename}\nLayer 1/{total_layers} done",
+    },
     {
         "event_type": "test",
         "name": "Test Notification",

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

@@ -58,6 +58,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - Bed cooled
     on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
 
+    # Event triggers - First layer complete
+    on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
+
     # Event triggers - Print queue
     on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
     on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
@@ -137,6 +140,9 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - Bed cooled
     on_bed_cooled: bool | None = None
 
+    # Event triggers - First layer complete
+    on_first_layer_complete: bool | None = None
+
     # Event triggers - Print queue
     on_queue_job_added: bool | None = None
     on_queue_job_assigned: bool | None = None

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

@@ -1142,6 +1142,31 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "bed_cooled", variables)
         await self._send_to_providers(providers, title, message, db, "bed_cooled", printer_id, printer_name)
 
+    async def on_first_layer_complete(
+        self,
+        printer_id: int,
+        printer_name: str,
+        filename: str,
+        total_layers: int,
+        db: AsyncSession,
+        image_data: bytes | None = None,
+    ):
+        """Handle first layer complete event."""
+        providers = await self._get_providers_for_event(db, "on_first_layer_complete", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "filename": self._clean_filename(filename),
+            "total_layers": str(total_layers),
+        }
+
+        title, message = await self._build_message_from_template(db, "first_layer_complete", variables)
+        await self._send_to_providers(
+            providers, title, message, db, "first_layer_complete", printer_id, printer_name, image_data=image_data
+        )
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()

+ 55 - 0
backend/tests/integration/test_notifications_api.py

@@ -336,6 +336,61 @@ class TestNotificationsAPI:
 
         assert response.status_code == 404
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_with_first_layer_complete(self, async_client: AsyncClient):
+        """Verify first layer complete toggle persists on create."""
+        data = {
+            "name": "First Layer Test",
+            "provider_type": "ntfy",
+            "config": {"server": "https://ntfy.sh", "topic": "test"},
+            "on_first_layer_complete": True,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_first_layer_complete"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_first_layer_complete_toggle(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """CRITICAL: Verify first layer complete toggle persists correctly."""
+        provider = await notification_provider_factory(on_first_layer_complete=False)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_first_layer_complete": True},
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_first_layer_complete"] is True
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_first_layer_complete"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_first_layer_complete_independent_from_other_toggles(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify first layer complete is independent from bed cooled and print complete."""
+        provider = await notification_provider_factory(
+            on_print_complete=True,
+            on_bed_cooled=False,
+            on_first_layer_complete=True,
+        )
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        result = response.json()
+        assert result["on_print_complete"] is True
+        assert result["on_bed_cooled"] is False
+        assert result["on_first_layer_complete"] is True
+
 
 class TestNotificationTemplatesAPI:
     """Integration tests for /api/v1/notification-templates/ endpoints."""

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

@@ -1573,3 +1573,127 @@ class TestBedCooledNotifications:
             )
 
             assert captured_variables["filename"] == "Unknown"
+
+
+class TestFirstLayerCompleteNotifications:
+    """Tests for first layer complete notifications."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider with first layer complete enabled."""
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_first_layer_complete = True
+        provider.quiet_hours_enabled = False
+        provider.daily_digest_enabled = False
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_sends_notification(self, service, mock_provider, mock_db):
+        """Verify first layer complete notification is sent when triggered."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("First Layer Complete", "Test Printer: benchy.3mf")
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="Test Printer",
+                filename="benchy.3mf",
+                total_layers=50,
+                db=mock_db,
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_skipped_when_no_providers(self, service, mock_db):
+        """Verify notification is skipped when no providers have first layer complete enabled."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+        ):
+            mock_get.return_value = []
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="Test Printer",
+                filename="benchy.3mf",
+                total_layers=50,
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_includes_correct_variables(self, service, mock_provider, mock_db):
+        """Verify printer name, filename, and total_layers are passed to template variables."""
+        captured_variables = {}
+
+        async def capture_build(db, event_type, variables):
+            captured_variables.update(variables)
+            return ("Test", "Test")
+
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock),
+            patch.object(service, "_build_message_from_template", side_effect=capture_build),
+        ):
+            mock_get.return_value = [mock_provider]
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                filename="benchy.gcode.3mf",
+                total_layers=120,
+                db=mock_db,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["filename"] == "benchy"
+            assert captured_variables["total_layers"] == "120"
+
+    @pytest.mark.asyncio
+    async def test_on_first_layer_complete_passes_image_data(self, service, mock_provider, mock_db):
+        """Verify image_data is passed through to _send_to_providers."""
+        with (
+            patch.object(service, "_get_providers_for_event", new_callable=AsyncMock) as mock_get,
+            patch.object(service, "_send_to_providers", new_callable=AsyncMock) as mock_send,
+            patch.object(service, "_build_message_from_template", new_callable=AsyncMock) as mock_build,
+        ):
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("First Layer Complete", "Test message")
+            fake_image = b"\x89PNG\r\n\x1a\nfakeimage"
+
+            await service.on_first_layer_complete(
+                printer_id=1,
+                printer_name="Test Printer",
+                filename="benchy.3mf",
+                total_layers=50,
+                db=mock_db,
+                image_data=fake_image,
+            )
+
+            mock_send.assert_called_once()
+            call_kwargs = mock_send.call_args
+            assert call_kwargs.kwargs.get("image_data") == fake_image

+ 44 - 0
frontend/src/__tests__/components/NotificationProviderCard.test.tsx

@@ -65,6 +65,7 @@ const createMockProvider = (
   on_ams_ht_temperature_high: false,
   on_plate_not_empty: true,
   on_bed_cooled: false,
+  on_first_layer_complete: false,
   on_queue_job_added: false,
   on_queue_job_assigned: false,
   on_queue_job_started: false,
@@ -403,6 +404,49 @@ describe('NotificationProviderCard Bed Cooled notifications', () => {
   });
 });
 
+describe('NotificationProviderCard First Layer Complete notifications', () => {
+  describe('first layer complete toggle', () => {
+    it('includes on_first_layer_complete in provider data when enabled', () => {
+      const provider = createMockProvider({ on_first_layer_complete: true });
+      expect(provider.on_first_layer_complete).toBe(true);
+    });
+
+    it('includes on_first_layer_complete in provider data when disabled', () => {
+      const provider = createMockProvider({ on_first_layer_complete: false });
+      expect(provider.on_first_layer_complete).toBe(false);
+    });
+
+    it('defaults on_first_layer_complete to false', () => {
+      const provider = createMockProvider();
+      expect(provider.on_first_layer_complete).toBe(false);
+    });
+
+    it('first layer complete is independent from other print event toggles', () => {
+      const provider = createMockProvider({
+        on_print_complete: true,
+        on_bed_cooled: true,
+        on_first_layer_complete: false,
+      });
+
+      expect(provider.on_print_complete).toBe(true);
+      expect(provider.on_bed_cooled).toBe(true);
+      expect(provider.on_first_layer_complete).toBe(false);
+    });
+
+    it('can enable first layer complete independently', () => {
+      const provider = createMockProvider({
+        on_print_complete: false,
+        on_bed_cooled: false,
+        on_first_layer_complete: true,
+      });
+
+      expect(provider.on_print_complete).toBe(false);
+      expect(provider.on_bed_cooled).toBe(false);
+      expect(provider.on_first_layer_complete).toBe(true);
+    });
+  });
+});
+
 describe('NotificationProviderCard Home Assistant provider', () => {
   describe('homeassistant provider type', () => {
     it('renders homeassistant provider type', () => {

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

@@ -1464,6 +1464,8 @@ export interface NotificationProvider {
   on_plate_not_empty: boolean;
   // Bed cooled
   on_bed_cooled: boolean;
+  // First layer complete
+  on_first_layer_complete: boolean;
   // Print queue events
   on_queue_job_added: boolean;
   on_queue_job_assigned: boolean;
@@ -1516,6 +1518,8 @@ export interface NotificationProviderCreate {
   on_plate_not_empty?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
+  // First layer complete
+  on_first_layer_complete?: boolean;
   // Print queue events
   on_queue_job_added?: boolean;
   on_queue_job_assigned?: boolean;
@@ -1561,6 +1565,8 @@ export interface NotificationProviderUpdate {
   on_plate_not_empty?: boolean;
   // Bed cooled
   on_bed_cooled?: boolean;
+  // First layer complete
+  on_first_layer_complete?: boolean;
   // Print queue events
   on_queue_job_added?: boolean;
   on_queue_job_assigned?: boolean;

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

@@ -41,6 +41,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);
   const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);
   const [onBedCooled, setOnBedCooled] = useState(provider?.on_bed_cooled ?? false);
+  const [onFirstLayerComplete, setOnFirstLayerComplete] = useState(provider?.on_first_layer_complete ?? false);
 
   // Provider-specific config
   const [config, setConfig] = useState<Record<string, string>>(
@@ -141,6 +142,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_filament_low: onFilamentLow,
       on_maintenance_due: onMaintenanceDue,
       on_bed_cooled: onBedCooled,
+      on_first_layer_complete: onFirstLayerComplete,
     };
 
     if (isEditing) {
@@ -491,6 +493,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onBedCooled} onChange={setOnBedCooled} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">{t('notifications.firstLayerCompleteLabel')}</span>
+                    <span className="text-xs text-bambu-gray ml-1">{t('notifications.firstLayerCompleteDescription')}</span>
+                  </div>
+                  <Toggle checked={onFirstLayerComplete} onChange={setOnFirstLayerComplete} />
+                </div>
               </div>
             </div>
 

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

@@ -158,6 +158,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_bed_cooled && (
               <span className="px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded">{t('notifications.bedCooled')}</span>
             )}
+            {provider.on_first_layer_complete && (
+              <span className="px-2 py-0.5 bg-emerald-600/20 text-emerald-300 text-xs rounded">{t('notifications.firstLayer')}</span>
+            )}
             {provider.quiet_hours_enabled && (
               <span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded flex items-center gap-1">
                 <Moon className="w-3 h-3" />
@@ -282,6 +285,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">{t('notifications.firstLayerCompleteLabel')}</p>
+                    <p className="text-xs text-bambu-gray">{t('notifications.firstLayerCompleteDescription')}</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_first_layer_complete ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_first_layer_complete: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">{t('notifications.printFailed')}</p>
                   <Toggle

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

@@ -3739,6 +3739,7 @@ export default {
     amsHtHumidity: 'AMS-HT-Feuchtigkeit',
     amsHtTemp: 'AMS-HT-Temperatur',
     bedCooled: 'Bett abgekühlt',
+    firstLayer: 'Erste Schicht',
     quiet: 'Ruhe',
     digest: 'Zusammenfassung {{time}}',
     // Event labels (expanded settings)
@@ -3748,6 +3749,8 @@ export default {
     printCompleted: 'Druck abgeschlossen',
     bedCooledLabel: 'Bett abgekühlt',
     bedCooledDescription: 'Bett nach dem Druck unter Schwellenwert abgekühlt',
+    firstLayerCompleteLabel: 'Erste Schicht fertig',
+    firstLayerCompleteDescription: 'Benachrichtigung mit Foto nach erster Schicht',
     printFailed: 'Druck fehlgeschlagen',
     printStopped: 'Druck gestoppt',
     progressMilestones: 'Fortschrittsmeilensteine',

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

@@ -3744,6 +3744,7 @@ export default {
     amsHtHumidity: 'AMS-HT Humidity',
     amsHtTemp: 'AMS-HT Temp',
     bedCooled: 'Bed Cooled',
+    firstLayer: 'First Layer',
     quiet: 'Quiet',
     digest: 'Digest {{time}}',
     // Event labels (expanded settings)
@@ -3753,6 +3754,8 @@ export default {
     printCompleted: 'Print Completed',
     bedCooledLabel: 'Bed Cooled',
     bedCooledDescription: 'Bed cooled below threshold after print',
+    firstLayerCompleteLabel: 'First Layer Complete',
+    firstLayerCompleteDescription: 'Notify with snapshot when first layer finishes',
     printFailed: 'Print Failed',
     printStopped: 'Print Stopped',
     progressMilestones: 'Progress Milestones',

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

@@ -3731,6 +3731,7 @@ export default {
     amsHtHumidity: 'Humidité AMS-HT',
     amsHtTemp: 'Temp. AMS-HT',
     bedCooled: 'Plateau refroidi',
+    firstLayer: 'Première couche',
     quiet: 'Silencieux',
     digest: 'Résumé {{time}}',
     // Event labels (expanded settings)
@@ -3740,6 +3741,8 @@ export default {
     printCompleted: 'Impression terminée',
     bedCooledLabel: 'Plateau refroidi',
     bedCooledDescription: 'Plateau refroidi sous le seuil après l\'impression',
+    firstLayerCompleteLabel: 'Première couche terminée',
+    firstLayerCompleteDescription: 'Notification avec photo après la première couche',
     printFailed: 'Impression échouée',
     printStopped: 'Impression arrêtée',
     progressMilestones: 'Jalons de progression',

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

@@ -3730,6 +3730,7 @@ export default {
     amsHtHumidity: 'Umidità AMS-HT',
     amsHtTemp: 'Temp AMS-HT',
     bedCooled: 'Piatto raffreddato',
+    firstLayer: 'Primo strato',
     quiet: 'Silenzioso',
     digest: 'Riepilogo {{time}}',
     // Event labels (expanded settings)
@@ -3739,6 +3740,8 @@ export default {
     printCompleted: 'Stampa completata',
     bedCooledLabel: 'Piatto raffreddato',
     bedCooledDescription: 'Piatto raffreddato sotto la soglia dopo la stampa',
+    firstLayerCompleteLabel: 'Primo strato completato',
+    firstLayerCompleteDescription: 'Notifica con foto al termine del primo strato',
     printFailed: 'Stampa fallita',
     printStopped: 'Stampa interrotta',
     progressMilestones: 'Traguardi di avanzamento',

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

@@ -3744,6 +3744,7 @@ export default {
     amsHtHumidity: 'AMS-HT湿度',
     amsHtTemp: 'AMS-HT温度',
     bedCooled: 'ベッド冷却済み',
+    firstLayer: '第1層完了',
     quiet: '静音',
     digest: 'ダイジェスト {{time}}',
     // Event labels (expanded settings)
@@ -3753,6 +3754,8 @@ export default {
     printCompleted: '印刷完了',
     bedCooledLabel: 'ベッド冷却済み',
     bedCooledDescription: '印刷後にベッドがしきい値以下に冷却',
+    firstLayerCompleteLabel: '第1層完了',
+    firstLayerCompleteDescription: '第1層完了時にスナップショット付きで通知',
     printFailed: '印刷失敗',
     printStopped: '印刷停止',
     progressMilestones: '進捗マイルストーン',

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

@@ -3730,6 +3730,7 @@ export default {
     amsHtHumidity: 'Umidade do AMS-HT',
     amsHtTemp: 'Temp. do AMS-HT',
     bedCooled: 'Mesa Resfriada',
+    firstLayer: 'Primeira camada',
     quiet: 'Silencioso',
     digest: 'Resumo {{time}}',
     // Event labels (expanded settings)
@@ -3739,6 +3740,8 @@ export default {
     printCompleted: 'Impressão Concluída',
     bedCooledLabel: 'Mesa Resfriada',
     bedCooledDescription: 'Mesa resfriou abaixo do limite após a impressão',
+    firstLayerCompleteLabel: 'Primeira camada concluída',
+    firstLayerCompleteDescription: 'Notificar com foto quando a primeira camada terminar',
     printFailed: 'Impressão Falhou',
     printStopped: 'Impressão Parada',
     progressMilestones: 'Marcos de Progresso',

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

@@ -3730,6 +3730,7 @@ export default {
     amsHtHumidity: 'AMS-HT 湿度',
     amsHtTemp: 'AMS-HT 温度',
     bedCooled: '热床已冷却',
+    firstLayer: '首层完成',
     quiet: '免打扰',
     digest: '摘要 {{time}}',
     // Event labels (expanded settings)
@@ -3739,6 +3740,8 @@ export default {
     printCompleted: '打印已完成',
     bedCooledLabel: '热床已冷却',
     bedCooledDescription: '打印后热床温度降至阈值以下',
+    firstLayerCompleteLabel: '首层打印完成',
+    firstLayerCompleteDescription: '首层完成时发送带照片的通知',
     printFailed: '打印失败',
     printStopped: '打印已停止',
     progressMilestones: '进度里程碑',

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


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


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


+ 2 - 2
static/index.html

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

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