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
 
 ### 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:
   - Edit and delete buttons inline with each row
   - Three-dot menu button for context menu access

+ 1 - 1
README.md

@@ -56,7 +56,7 @@
 - Real-time printer status via WebSocket
 - Live camera streaming (MJPEG) & snapshots
 - 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)
 - Skip objects during print
 - AMS slot RFID re-read

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

@@ -3,7 +3,7 @@ import logging
 import re
 import zipfile
 
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
 from fastapi.responses import Response
 from sqlalchemy import select
 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"}
 
 
+@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")
 async def get_printable_objects(
     printer_id: int,

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

@@ -1333,6 +1333,12 @@ export const api = {
       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
   getPrintableObjects: (printerId: number) =>
     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 { LinkSpoolModal } from '../components/LinkSpoolModal';
 import { useToast } from '../contexts/ToastContext';
+import { ChamberLight } from '../components/icons/ChamberLight';
 
 // Complete Bambu Lab filament color mapping by tray_id_name
 // 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'),
   });
 
+  // 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)
   // 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;
@@ -2360,6 +2388,18 @@ function PrinterCard({
               <p className="truncate">{printer.serial_number}</p>
             </div>
             <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
                 variant="secondary"
                 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 -->
     <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">
   </head>
   <body>

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