Bläddra i källkod

feat: add bed cooled notification after print completes (#378)

Notify users when the print bed cools below a configurable threshold
(default 35°C) after a print finishes, so they know when to remove parts.

- Backend: DB migration, model, schemas, notification template, service
  method, background cooldown monitor (polls every 15s, 30min timeout)
- Frontend: event toggle in provider card/modal, threshold setting in
  Settings > Notifications, i18n keys for all 5 locales
- Tests: 4 backend + 4 frontend tests
- Docs: README, website, wiki updated
maziggy 3 månader sedan
förälder
incheckning
53a4933c36

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.2.0b] - Not released
 
 ### New Features
+- **Bed Cooled Notification** ([#378](https://github.com/maziggy/bambuddy/issues/378)) — New notification event that fires when the print bed cools below a configurable threshold (default 35°C) after a print completes. Useful for knowing when it's safe to remove parts. A background task polls the bed temperature every 15 seconds after print completion and sends a notification when it drops below the threshold. Automatically cancels if a new print starts or the printer disconnects. The threshold is configurable in Settings → Notifications. Includes a customizable notification template with printer name, bed temperature, and threshold variables.
 - **Spool Inventory — AMS Slot Assignment** — Assign inventory spools to AMS slots for filament tracking. Hover over any non-Bambu-Lab AMS slot to assign or unassign spools. The assign modal filters out Bambu Lab spools (tracked via RFID) and spools already assigned to other slots. Bambu Lab spool slots automatically hide assign/unassign UI since they are managed by the AMS. When a Bambu Lab spool is inserted into a slot with a manual assignment, the assignment is automatically unlinked.
 - **Spool Inventory — Remaining Weight Editing** — Edit the remaining filament weight when adding or editing a spool. The new "Remaining Weight" field in the Additional section shows current weight (label weight minus consumed) with a max reference. Edits are stored as `weight_used` internally.
 - **Spool Inventory — Unified 3MF-Based Usage Tracking** ([#336](https://github.com/maziggy/bambuddy/issues/336)) — All spools (Bambu Lab and third-party) now use 3MF slicer estimates as the primary tracking source. Per-filament `used_g` data from the archived 3MF file provides precise per-spool consumption. For failed or aborted prints, per-layer G-code analysis provides accurate partial usage up to the exact failure layer, with linear progress scaling as fallback. AMS remain% delta is the final fallback for G-code-only prints without an archived 3MF. Slot-to-tray mapping uses queue `ams_mapping` for queue-initiated prints and the printer's `tray_now` state for single-filament non-queue prints, ensuring the correct physical spool is always tracked.

+ 1 - 0
README.md

@@ -146,6 +146,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
+- Bed cooled alerts (configurable threshold)
 - Queue events (waiting, skipped, failed)
 
 ### 🧵 Spool Inventory

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

@@ -56,6 +56,8 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
         # Build plate detection
         "on_plate_not_empty": provider.on_plate_not_empty,
+        # Bed cooled
+        "on_bed_cooled": provider.on_bed_cooled,
         # Print queue events
         "on_queue_job_added": provider.on_queue_job_added,
         "on_queue_job_assigned": provider.on_queue_job_assigned,

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

@@ -1209,6 +1209,12 @@ async def run_migrations(conn):
     except OperationalError:
         pass  # Already applied
 
+    # Migration: Add bed cooled notification column to notification_providers
+    try:
+        await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_bed_cooled BOOLEAN DEFAULT 0"))
+    except OperationalError:
+        pass  # Already applied
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 90 - 0
backend/app/main.py

@@ -260,6 +260,9 @@ _notified_hms_errors: dict[int, set[str]] = {}
 # Used for snapshot-diff detection at print completion
 _timelapse_baselines: dict[int, set[str]] = {}
 
+# Track active bed cooldown monitoring tasks: {printer_id: asyncio.Task}
+_bed_cooldown_tasks: dict[int, asyncio.Task] = {}
+
 
 async def _get_plug_energy(plug, db) -> dict | None:
     """Get energy from plug regardless of type (Tasmota, Home Assistant, or MQTT).
@@ -991,6 +994,12 @@ 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()))
 
+    # Cancel any active bed cooldown task for this printer
+    existing_task = _bed_cooldown_tasks.pop(printer_id, None)
+    if existing_task and not existing_task.done():
+        existing_task.cancel()
+        logger.info("[BED-COOL] Cancelled bed cooldown monitor for printer %s (new print started)", printer_id)
+
     # Clear cached cover images so the new print's thumbnail is fetched fresh
     from backend.app.api.routes.printers import clear_cover_cache
 
@@ -2519,6 +2528,87 @@ async def on_print_complete(printer_id: int, data: dict):
                 pass  # Best-effort timelapse session cancellation on error
 
     asyncio.create_task(_background_layer_timelapse())
+
+    # Start bed cooldown monitor (polls bed temp until it drops below threshold)
+    async def _background_bed_cooldown():
+        """Monitor bed temperature after print and notify when cooled."""
+        try:
+            from backend.app.api.routes.settings import get_setting
+
+            # Check threshold setting
+            async with async_session() as db:
+                threshold_str = await get_setting(db, "bed_cooled_threshold")
+            threshold = float(threshold_str) if threshold_str else 35.0
+
+            # Check if any provider has on_bed_cooled enabled (early exit if none)
+            async with async_session() as db:
+                providers = await notification_service._get_providers_for_event(db, "on_bed_cooled", printer_id)
+                if not providers:
+                    logger.debug("[BED-COOL] No providers enabled for bed_cooled on printer %s", printer_id)
+                    return
+
+            logger.info("[BED-COOL] Monitoring bed temp for printer %s (threshold: %.0f°C)", printer_id, threshold)
+
+            max_polls = 120  # 120 * 15s = 30 min timeout
+            for _ in range(max_polls):
+                await asyncio.sleep(15)
+
+                # Check if printer is still connected
+                status = printer_manager.get_status(printer_id)
+                if status is None:
+                    logger.info("[BED-COOL] Printer %s disconnected, stopping monitor", printer_id)
+                    return
+
+                # Check if a new print started (state == RUNNING)
+                if hasattr(status, "state") and status.state == "RUNNING":
+                    logger.info("[BED-COOL] New print started on printer %s, stopping monitor", printer_id)
+                    return
+
+                # Get bed temperature
+                bed_temp = None
+                if hasattr(status, "temperatures") and status.temperatures:
+                    bed_temp = status.temperatures.get("bed")
+
+                if bed_temp is None:
+                    continue
+
+                if bed_temp <= threshold:
+                    logger.info(
+                        "[BED-COOL] Bed cooled to %.1f°C on printer %s (threshold: %.0f°C)",
+                        bed_temp,
+                        printer_id,
+                        threshold,
+                    )
+                    printer_info = printer_manager.get_printer(printer_id)
+                    p_name = printer_info.name if printer_info else "Unknown"
+                    async with async_session() as db:
+                        await notification_service.on_bed_cooled(
+                            printer_id=printer_id,
+                            printer_name=p_name,
+                            bed_temp=bed_temp,
+                            threshold=threshold,
+                            filename=filename or subtask_name or "",
+                            db=db,
+                        )
+                    return
+
+            logger.info("[BED-COOL] Timeout waiting for bed to cool on printer %s", printer_id)
+        except asyncio.CancelledError:
+            logger.info("[BED-COOL] Bed cooldown monitor cancelled for printer %s", printer_id)
+        except Exception as e:
+            logger.warning("[BED-COOL] Failed: %s", e)
+        finally:
+            _bed_cooldown_tasks.pop(printer_id, None)
+
+    # Only start bed cooldown for completed prints
+    if data.get("status") == "completed":
+        # Cancel any existing task for this printer
+        existing_task = _bed_cooldown_tasks.pop(printer_id, None)
+        if existing_task and not existing_task.done():
+            existing_task.cancel()
+        task = asyncio.create_task(_background_bed_cooldown())
+        _bed_cooldown_tasks[printer_id] = task
+
     log_timing("All background tasks scheduled")
 
     # Auto-scan for timelapse if recording was active during the print

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

@@ -83,6 +83,9 @@ class NotificationProvider(Base):
     # Event triggers - Build plate detection
     on_plate_not_empty = Column(Boolean, default=True)  # Objects detected on plate before print
 
+    # Event triggers - Bed cooled after print
+    on_bed_cooled = Column(Boolean, default=False)  # Bed cooled below threshold after print
+
     # Event triggers - Print queue
     on_queue_job_added = Column(Boolean, default=False)  # Job added to queue
     on_queue_job_assigned = Column(Boolean, default=False)  # Model-based job assigned to printer

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

@@ -97,6 +97,12 @@ DEFAULT_TEMPLATES = [
         "title_template": "AMS Temperature Alert",
         "body_template": "{printer} {ams_label}: Temperature {temperature}°C exceeds {threshold}°C threshold",
     },
+    {
+        "event_type": "bed_cooled",
+        "name": "Bed Cooled",
+        "title_template": "Bed Cooled",
+        "body_template": "{printer}: Bed cooled to {bed_temp}°C (threshold: {threshold}°C)",
+    },
     {
         "event_type": "test",
         "name": "Test Notification",

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

@@ -53,6 +53,9 @@ class NotificationProviderBase(BaseModel):
     # Event triggers - Build plate detection
     on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
 
+    # Event triggers - Bed cooled
+    on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
+
     # 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")
@@ -129,6 +132,9 @@ class NotificationProviderUpdate(BaseModel):
     # Event triggers - Build plate detection
     on_plate_not_empty: bool | None = None
 
+    # Event triggers - Bed cooled
+    on_bed_cooled: bool | None = None
+
     # Event triggers - Print queue
     on_queue_job_added: bool | None = None
     on_queue_job_assigned: bool | None = None

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

@@ -20,6 +20,7 @@ class EventType(StrEnum):
     MAINTENANCE_DUE = "maintenance_due"
     AMS_HUMIDITY_HIGH = "ams_humidity_high"
     AMS_TEMPERATURE_HIGH = "ams_temperature_high"
+    BED_COOLED = "bed_cooled"
     TEST = "test"
 
 
@@ -66,6 +67,7 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
     "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
     "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
+    "bed_cooled": ["printer", "bed_temp", "threshold", "filename", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
     # Queue notifications
     "queue_job_added": ["job_name", "target", "timestamp", "app_name"],
@@ -172,6 +174,14 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "bed_cooled": {
+        "printer": "Bambu X1C",
+        "bed_temp": "34",
+        "threshold": "35",
+        "filename": "Benchy",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

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

@@ -39,6 +39,11 @@ class AppSettings(BaseModel):
     # Language
     notification_language: str = Field(default="en", description="Language for push notifications (en, de)")
 
+    # Bed cooled notification threshold
+    bed_cooled_threshold: float = Field(
+        default=35.0, description="Bed temperature threshold for cooled notification (°C)"
+    )
+
     # AMS threshold settings for humidity and temperature coloring
     ams_humidity_good: int = Field(default=40, description="Humidity threshold for good (green): <= this value")
     ams_humidity_fair: int = Field(
@@ -161,6 +166,7 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     notification_language: str | None = None
+    bed_cooled_threshold: float | None = None
     ams_humidity_good: int | None = None
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None

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

@@ -1016,6 +1016,30 @@ class NotificationService:
             providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True
         )
 
+    async def on_bed_cooled(
+        self,
+        printer_id: int,
+        printer_name: str,
+        bed_temp: float,
+        threshold: float,
+        filename: str,
+        db: AsyncSession,
+    ):
+        """Handle bed cooled event - bed temperature dropped below threshold after print."""
+        providers = await self._get_providers_for_event(db, "on_bed_cooled", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "bed_temp": f"{bed_temp:.0f}",
+            "threshold": f"{threshold:.0f}",
+            "filename": self._clean_filename(filename) if filename else "Unknown",
+        }
+
+        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)
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()

+ 1 - 0
backend/tests/conftest.py

@@ -408,6 +408,7 @@ def notification_provider_factory(db_session):
             "on_maintenance_due": False,
             "on_ams_humidity_high": False,
             "on_ams_temperature_high": False,
+            "on_bed_cooled": False,
             "quiet_hours_enabled": False,
             "daily_digest_enabled": False,
         }

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

@@ -1191,3 +1191,133 @@ class TestPlateNotEmptyNotifications:
 
             assert captured_variables["printer"] == "X1 Carbon"
             assert captured_variables["difference_percent"] == "3.5"
+
+
+class TestBedCooledNotifications:
+    """Tests for bed cooled (after print) notifications."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider with bed cooled 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_bed_cooled = 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_bed_cooled_sends_notification(self, service, mock_provider, mock_db):
+        """Verify bed cooled 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 = ("Bed Cooled", "Test Printer: Bed cooled to 30°C")
+
+            await service.on_bed_cooled(
+                printer_id=1,
+                printer_name="Test Printer",
+                bed_temp=30.0,
+                threshold=35.0,
+                filename="benchy.3mf",
+                db=mock_db,
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_skipped_when_no_providers(self, service, mock_db):
+        """Verify notification is skipped when no providers have bed cooled 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_bed_cooled(
+                printer_id=1,
+                printer_name="Test Printer",
+                bed_temp=30.0,
+                threshold=35.0,
+                filename="benchy.3mf",
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_includes_correct_variables(self, service, mock_provider, mock_db):
+        """Verify bed temp, threshold, and filename 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_bed_cooled(
+                printer_id=1,
+                printer_name="X1 Carbon",
+                bed_temp=28.7,
+                threshold=35.0,
+                filename="benchy.gcode.3mf",
+                db=mock_db,
+            )
+
+            assert captured_variables["printer"] == "X1 Carbon"
+            assert captured_variables["bed_temp"] == "29"
+            assert captured_variables["threshold"] == "35"
+            assert captured_variables["filename"] == "benchy"
+
+    @pytest.mark.asyncio
+    async def test_on_bed_cooled_handles_none_filename(self, service, mock_provider, mock_db):
+        """Verify None filename is handled gracefully."""
+        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_bed_cooled(
+                printer_id=1,
+                printer_name="Test Printer",
+                bed_temp=30.0,
+                threshold=35.0,
+                filename=None,
+                db=mock_db,
+            )
+
+            assert captured_variables["filename"] == "Unknown"

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

@@ -64,6 +64,7 @@ const createMockProvider = (
   on_ams_ht_humidity_high: false,
   on_ams_ht_temperature_high: false,
   on_plate_not_empty: true,
+  on_bed_cooled: false,
   on_queue_job_added: false,
   on_queue_job_assigned: false,
   on_queue_job_started: false,
@@ -370,3 +371,34 @@ describe('NotificationProviderCard Queue notifications', () => {
     });
   });
 });
+
+describe('NotificationProviderCard Bed Cooled notifications', () => {
+  describe('bed cooled toggle', () => {
+    it('includes on_bed_cooled in provider data when enabled', () => {
+      const provider = createMockProvider({ on_bed_cooled: true });
+      expect(provider.on_bed_cooled).toBe(true);
+    });
+
+    it('includes on_bed_cooled in provider data when disabled', () => {
+      const provider = createMockProvider({ on_bed_cooled: false });
+      expect(provider.on_bed_cooled).toBe(false);
+    });
+
+    it('defaults on_bed_cooled to false', () => {
+      const provider = createMockProvider();
+      expect(provider.on_bed_cooled).toBe(false);
+    });
+
+    it('bed cooled is independent from other print event toggles', () => {
+      const provider = createMockProvider({
+        on_print_complete: true,
+        on_bed_cooled: true,
+        on_plate_not_empty: false,
+      });
+
+      expect(provider.on_print_complete).toBe(true);
+      expect(provider.on_bed_cooled).toBe(true);
+      expect(provider.on_plate_not_empty).toBe(false);
+    });
+  });
+});

+ 1 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -32,6 +32,7 @@ const mockSettings = {
   ha_token: '',
   check_updates: false,
   check_printer_firmware: false,
+  bed_cooled_threshold: 35,
 };
 
 describe('SettingsPage', () => {

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

@@ -791,6 +791,8 @@ export interface AppSettings {
   // Prometheus metrics
   prometheus_enabled: boolean;
   prometheus_token: string;
+  // Bed cooled threshold
+  bed_cooled_threshold: number;
 }
 
 export type AppSettingsUpdate = Partial<AppSettings>;
@@ -1402,6 +1404,8 @@ export interface NotificationProvider {
   on_ams_ht_temperature_high: boolean;
   // Build plate detection
   on_plate_not_empty: boolean;
+  // Bed cooled
+  on_bed_cooled: boolean;
   // Print queue events
   on_queue_job_added: boolean;
   on_queue_job_assigned: boolean;
@@ -1452,6 +1456,8 @@ export interface NotificationProviderCreate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  // Bed cooled
+  on_bed_cooled?: boolean;
   // Print queue events
   on_queue_job_added?: boolean;
   on_queue_job_assigned?: boolean;
@@ -1495,6 +1501,8 @@ export interface NotificationProviderUpdate {
   on_ams_ht_temperature_high?: boolean;
   // Build plate detection
   on_plate_not_empty?: boolean;
+  // Bed cooled
+  on_bed_cooled?: boolean;
   // Print queue events
   on_queue_job_added?: boolean;
   on_queue_job_assigned?: boolean;

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

@@ -46,6 +46,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
   const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);
   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);
 
   // Provider-specific config
   const [config, setConfig] = useState<Record<string, string>>(
@@ -145,6 +146,7 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
       on_printer_error: onPrinterError,
       on_filament_low: onFilamentLow,
       on_maintenance_due: onMaintenanceDue,
+      on_bed_cooled: onBedCooled,
     };
 
     if (isEditing) {
@@ -486,6 +488,13 @@ export function AddNotificationModal({ provider, onClose }: AddNotificationModal
                   </div>
                   <Toggle checked={onPrintProgress} onChange={setOnPrintProgress} />
                 </div>
+                <div className="flex items-center justify-between col-span-2">
+                  <div>
+                    <span className="text-sm text-white">Bed Cooled</span>
+                    <span className="text-xs text-bambu-gray ml-1">(after print completes)</span>
+                  </div>
+                  <Toggle checked={onBedCooled} onChange={setOnBedCooled} />
+                </div>
               </div>
             </div>
 

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

@@ -163,6 +163,9 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
             {provider.on_ams_ht_temperature_high && (
               <span className="px-2 py-0.5 bg-amber-600/20 text-amber-300 text-xs rounded">AMS-HT Temp</span>
             )}
+            {provider.on_bed_cooled && (
+              <span className="px-2 py-0.5 bg-teal-500/20 text-teal-400 text-xs rounded">Bed Cooled</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" />
@@ -276,6 +279,17 @@ export function NotificationProviderCard({ provider, onEdit }: NotificationProvi
                   />
                 </div>
 
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-sm text-white">Bed Cooled</p>
+                    <p className="text-xs text-bambu-gray">Bed cooled below threshold after print</p>
+                  </div>
+                  <Toggle
+                    checked={provider.on_bed_cooled ?? false}
+                    onChange={(checked) => updateMutation.mutate({ on_bed_cooled: checked })}
+                  />
+                </div>
+
                 <div className="flex items-center justify-between">
                   <p className="text-sm text-white">Print Failed</p>
                   <Toggle

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

@@ -1118,6 +1118,8 @@ export default {
     // Notifications
     notificationLanguage: 'Benachrichtigungssprache',
     notificationLanguageDescription: 'Sprache für Push-Benachrichtigungen',
+    bedCooledThreshold: 'Bett-Abkühlung Schwellenwert',
+    bedCooledThresholdDescription: 'Temperatur, unter der das Bett nach einem Druck als abgekühlt gilt',
     notificationProviders: 'Benachrichtigungsanbieter',
     addProvider: 'Anbieter hinzufügen',
     editProvider: 'Anbieter bearbeiten',

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

@@ -1118,6 +1118,8 @@ export default {
     // Notifications
     notificationLanguage: 'Notification Language',
     notificationLanguageDescription: 'Language for push notifications',
+    bedCooledThreshold: 'Bed Cooled Threshold',
+    bedCooledThresholdDescription: 'Temperature below which the bed is considered cooled after a print',
     notificationProviders: 'Notification Providers',
     addProvider: 'Add Provider',
     editProvider: 'Edit Provider',

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

@@ -1114,6 +1114,8 @@ export default {
     // Notifications
     notificationLanguage: 'Langue des notifications',
     notificationLanguageDescription: 'Langue pour les notifications push',
+    bedCooledThreshold: 'Seuil de refroidissement du plateau',
+    bedCooledThresholdDescription: 'Température en dessous de laquelle le plateau est considéré comme refroidi',
     notificationProviders: 'Fournisseurs de notifications',
     addProvider: 'Ajouter un fournisseur',
     editProvider: 'Modifier le fournisseur',

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

@@ -1051,6 +1051,8 @@ export default {
     // Notifications
     notificationLanguage: 'Lingua notifiche',
     notificationLanguageDescription: 'Lingua per notifiche push',
+    bedCooledThreshold: 'Soglia raffreddamento piatto',
+    bedCooledThresholdDescription: 'Temperatura sotto la quale il piatto è considerato raffreddato dopo una stampa',
     notificationProviders: 'Provider notifiche',
     addProvider: 'Aggiungi provider',
     editProvider: 'Modifica provider',

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

@@ -1108,6 +1108,8 @@ export default {
     updateAvailable: 'アップデートあり',
     notificationLanguage: '通知の言語',
     notificationLanguageDescription: 'プッシュ通知の言語',
+    bedCooledThreshold: 'ベッド冷却しきい値',
+    bedCooledThresholdDescription: '印刷後にベッドが冷却されたと見なす温度',
     notificationProviders: '通知プロバイダー',
     addProvider: 'プロバイダーを追加',
     editProvider: 'プロバイダーを編集',

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

@@ -773,6 +773,7 @@ export function SettingsPage() {
       settings.check_updates !== localSettings.check_updates ||
       (settings.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       settings.notification_language !== localSettings.notification_language ||
+      (settings.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
       settings.ams_humidity_good !== localSettings.ams_humidity_good ||
       settings.ams_humidity_fair !== localSettings.ams_humidity_fair ||
       settings.ams_temp_good !== localSettings.ams_temp_good ||
@@ -837,6 +838,7 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         notification_language: localSettings.notification_language,
+        bed_cooled_threshold: localSettings.bed_cooled_threshold,
         ams_humidity_good: localSettings.ams_humidity_good,
         ams_humidity_fair: localSettings.ams_humidity_fair,
         ams_temp_good: localSettings.ams_temp_good,
@@ -2696,6 +2698,30 @@ export function SettingsPage() {
               </CardContent>
             </Card>
 
+            {/* Bed Cooled Threshold Setting */}
+            <Card className="mb-4">
+              <CardContent className="py-3">
+                <div className="flex items-center justify-between">
+                  <div>
+                    <p className="text-white text-sm font-medium">{t('settings.bedCooledThreshold')}</p>
+                    <p className="text-xs text-bambu-gray">{t('settings.bedCooledThresholdDescription')}</p>
+                  </div>
+                  <div className="flex items-center gap-1">
+                    <input
+                      type="number"
+                      min={20}
+                      max={80}
+                      step={1}
+                      value={localSettings.bed_cooled_threshold ?? 35}
+                      onChange={(e) => updateSetting('bed_cooled_threshold', Number(e.target.value))}
+                      className="w-16 px-2 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm text-center focus:outline-none focus:ring-1 focus:ring-bambu-green"
+                    />
+                    <span className="text-sm text-bambu-gray">°C</span>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
             {/* Test All Results */}
             {testAllResult && (
               <Card className="mb-4">
@@ -2790,7 +2816,7 @@ export function SettingsPage() {
               </div>
             ) : notificationTemplates && notificationTemplates.length > 0 ? (
               <div className="space-y-2">
-                {notificationTemplates.map((template) => (
+                {[...notificationTemplates].sort((a, b) => a.name.localeCompare(b.name)).map((template) => (
                   <Card
                     key={template.id}
                     className="cursor-pointer hover:border-bambu-green/50 transition-colors"

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 0 - 0
static/assets/index-BwlOtO_N.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DgABqL-m.js"></script>
+    <script type="module" crossorigin src="/assets/index-BwlOtO_N.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-OqmBOPoC.css">
   </head>
   <body>

Vissa filer visades inte eftersom för många filer har ändrats