Browse Source

Add chamber light control to printer card
- Add light toggle button next to camera button at bottom of card
- Custom ChamberLight SVG icon with on/off states (yellow fill + rays when on)
- Backend endpoint: POST /printers/{id}/chamber-light?on=true|false
- Optimistic UI update for instant feedback
- Works on all Bambu Lab printers (X1, P1, A1, H2D)
- H2D dual chamber lights controlled together

Closes #67

maziggy 4 months ago
parent
commit
6f6df1e339

+ 5 - 0
CHANGELOG.md

@@ -5,6 +5,11 @@ All notable changes to Bambuddy will be documented in this file.
 ## [0.1.6b9] - 2026-01-09
 ## [0.1.6b9] - 2026-01-09
 
 
 ### Added
 ### Added
+- **Chamber light control** - Toggle the printer's chamber LED from the UI:
+  - Light button next to camera button at bottom of printer card
+  - Custom icon with on/off states (yellow when lit)
+  - Works on all Bambu Lab printers (X1, P1, A1 series, H2D)
+  - H2D dual chamber lights controlled together
 - **Archive list view improvements** - Full feature parity with card view:
 - **Archive list view improvements** - Full feature parity with card view:
   - Edit and delete buttons inline with each row
   - Edit and delete buttons inline with each row
   - Three-dot menu button for context menu access
   - Three-dot menu button for context menu access

+ 1 - 1
README.md

@@ -56,7 +56,7 @@
 - Real-time printer status via WebSocket
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
 - Live camera streaming (MJPEG) & snapshots
 - Fan status monitoring (part cooling, auxiliary, chamber)
 - Fan status monitoring (part cooling, auxiliary, chamber)
-- Printer control (stop, pause, resume)
+- Printer control (stop, pause, resume, chamber light)
 - Resizable printer cards (S/M/L/XL)
 - Resizable printer cards (S/M/L/XL)
 - Skip objects during print
 - Skip objects during print
 - AMS slot RFID re-read
 - AMS slot RFID re-read

+ 24 - 1
backend/app/api/routes/printers.py

@@ -3,7 +3,7 @@ import logging
 import re
 import re
 import zipfile
 import zipfile
 
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
 from fastapi.responses import Response
 from sqlalchemy import select
 from sqlalchemy import select
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -1154,6 +1154,29 @@ async def resume_print(printer_id: int, db: AsyncSession = Depends(get_db)):
     return {"success": True, "message": "Print resume command sent"}
     return {"success": True, "message": "Print resume command sent"}
 
 
 
 
+@router.post("/{printer_id}/chamber-light")
+async def set_chamber_light(
+    printer_id: int,
+    on: bool = Query(..., description="True to turn on, False to turn off"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Turn the chamber light on or off."""
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    client = printer_manager.get_client(printer_id)
+    if not client:
+        raise HTTPException(400, "Printer not connected")
+
+    success = client.set_chamber_light(on)
+    if not success:
+        raise HTTPException(500, "Failed to control chamber light")
+
+    return {"success": True, "message": f"Chamber light {'on' if on else 'off'}"}
+
+
 @router.get("/{printer_id}/print/objects")
 @router.get("/{printer_id}/print/objects")
 async def get_printable_objects(
 async def get_printable_objects(
     printer_id: int,
     printer_id: int,

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

@@ -1333,6 +1333,12 @@ export const api = {
       method: 'POST',
       method: 'POST',
     }),
     }),
 
 
+  // Chamber Light Control
+  setChamberLight: (printerId: number, on: boolean) =>
+    request<{ success: boolean; message: string }>(`/printers/${printerId}/chamber-light?on=${on}`, {
+      method: 'POST',
+    }),
+
   // Skip Objects
   // Skip Objects
   getPrintableObjects: (printerId: number) =>
   getPrintableObjects: (printerId: number) =>
     request<{
     request<{

+ 48 - 0
frontend/src/components/icons/ChamberLight.tsx

@@ -0,0 +1,48 @@
+interface ChamberLightProps {
+  on: boolean;
+  className?: string;
+}
+
+/**
+ * Chamber light icon with on/off states.
+ * Modern bulb design with radiating rays.
+ * - On: Filled yellow bulb with visible rays
+ * - Off: Outline only, muted color
+ */
+export function ChamberLight({ on, className = "w-5 h-5" }: ChamberLightProps) {
+  const bulbFill = on ? "#facc15" : "none"; // yellow-400 when on
+  const strokeColor = on ? "#78350f" : "currentColor"; // amber-900 when on
+  const rayOpacity = on ? 1 : 0;
+
+  return (
+    <svg
+      viewBox="0 0 32 32"
+      fill="none"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      className={className}
+    >
+      {/* Radiating rays */}
+      <g stroke={strokeColor} opacity={rayOpacity}>
+        <line x1="16" y1="2" x2="16" y2="6" />
+        <line x1="6.1" y1="6.1" x2="8.9" y2="8.9" />
+        <line x1="25.9" y1="6.1" x2="23.1" y2="8.9" />
+        <line x1="2" y1="16" x2="6" y2="16" />
+        <line x1="30" y1="16" x2="26" y2="16" />
+      </g>
+
+      {/* Bulb glass - smooth rounded shape */}
+      <path
+        d="M12 24v-2.3c0-.9-.4-1.7-1-2.3C9.2 17.6 8 15.4 8 13c0-4.4 3.6-8 8-8s8 3.6 8 8c0 2.4-1.2 4.6-3 6.4-.6.6-1 1.4-1 2.3V24"
+        fill={bulbFill}
+        stroke={strokeColor}
+      />
+
+      {/* Base rings */}
+      <path d="M12 24h8" stroke={strokeColor} />
+      <path d="M12 27h8" stroke={strokeColor} />
+      <path d="M13 30h6" stroke={strokeColor} />
+    </svg>
+  );
+}

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

@@ -65,6 +65,7 @@ import { AMSHistoryModal } from '../components/AMSHistoryModal';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { FilamentHoverCard, EmptySlotHoverCard } from '../components/FilamentHoverCard';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { ChamberLight } from '../components/icons/ChamberLight';
 
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Complete Bambu Lab filament color mapping by tray_id_name
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
 // Source: https://github.com/queengooborg/Bambu-Lab-RFID-Library
@@ -1093,6 +1094,33 @@ function PrinterCard({
     onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'),
     onError: (error: Error) => showToast(error.message || 'Failed to resume print', 'error'),
   });
   });
 
 
+  // Chamber light mutation with optimistic update
+  const chamberLightMutation = useMutation({
+    mutationFn: (on: boolean) => api.setChamberLight(printer.id, on),
+    onMutate: async (on) => {
+      // Cancel any outgoing refetches
+      await queryClient.cancelQueries({ queryKey: ['printerStatus', printer.id] });
+      // Snapshot the previous value
+      const previousStatus = queryClient.getQueryData(['printerStatus', printer.id]);
+      // Optimistically update
+      queryClient.setQueryData(['printerStatus', printer.id], (old: typeof status) => ({
+        ...old,
+        chamber_light: on,
+      }));
+      return { previousStatus };
+    },
+    onSuccess: (_, on) => {
+      showToast(`Chamber light ${on ? 'on' : 'off'}`);
+    },
+    onError: (error: Error, _, context) => {
+      // Rollback on error
+      if (context?.previousStatus) {
+        queryClient.setQueryData(['printerStatus', printer.id], context.previousStatus);
+      }
+      showToast(error.message || 'Failed to control chamber light', 'error');
+    },
+  });
+
   // Query for printable objects (for skip functionality)
   // Query for printable objects (for skip functionality)
   // Fetch when printing with 2+ objects OR when modal is open
   // Fetch when printing with 2+ objects OR when modal is open
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
   const isPrintingWithObjects = (status?.state === 'RUNNING' || status?.state === 'PAUSE' || status?.state === 'PAUSED') && (status?.printable_objects_count ?? 0) >= 2;
@@ -2360,6 +2388,18 @@ function PrinterCard({
               <p className="truncate">{printer.serial_number}</p>
               <p className="truncate">{printer.serial_number}</p>
             </div>
             </div>
             <div className="flex items-center gap-2">
             <div className="flex items-center gap-2">
+              {/* Chamber Light Toggle */}
+              <Button
+                variant="secondary"
+                size="sm"
+                onClick={() => chamberLightMutation.mutate(!status?.chamber_light)}
+                disabled={!status?.connected || chamberLightMutation.isPending}
+                title={status?.chamber_light ? 'Turn off chamber light' : 'Turn on chamber light'}
+                className={status?.chamber_light ? 'bg-yellow-500/20 hover:bg-yellow-500/30 border-yellow-500/30' : ''}
+              >
+                <ChamberLight on={status?.chamber_light ?? false} className="w-4 h-4" />
+              </Button>
+              {/* Camera Button */}
               <Button
               <Button
                 variant="secondary"
                 variant="secondary"
                 size="sm"
                 size="sm"

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


+ 1 - 1
static/index.html

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

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