From 6f6df1e339a5f6d8ef5821f72a44bc9b18b6a86e Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 11 Jan 2026 08:47:13 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 5 + README.md | 2 +- backend/app/api/routes/printers.py | 25 +- frontend/src/api/client.ts | 6 + .../src/components/icons/ChamberLight.tsx | 48 ++ frontend/src/pages/PrintersPage.tsx | 40 + .../{index-CCWSzj3L.js => index-ClrkCtKg.js} | 754 +++++++++--------- static/index.html | 2 +- 8 files changed, 502 insertions(+), 380 deletions(-) create mode 100644 frontend/src/components/icons/ChamberLight.tsx rename static/assets/{index-CCWSzj3L.js => index-ClrkCtKg.js} (81%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666619a3e..b9352705c 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 891fc8a9b..2a1f076d5 100644 --- a/README.md +++ b/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 diff --git a/backend/app/api/routes/printers.py b/backend/app/api/routes/printers.py index 31e9ef23f..106a8a53c 100644 --- a/backend/app/api/routes/printers.py +++ b/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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 12cbe5787..f4cbf44d8 100644 --- a/frontend/src/api/client.ts +++ b/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<{ diff --git a/frontend/src/components/icons/ChamberLight.tsx b/frontend/src/components/icons/ChamberLight.tsx new file mode 100644 index 000000000..06991ccde --- /dev/null +++ b/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 ( + + {/* Radiating rays */} + + + + + + + + + {/* Bulb glass - smooth rounded shape */} + + + {/* Base rings */} + + + + + ); +} diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index b7a2ba6ba..5dcdd444e 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/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({

{printer.serial_number}

+ {/* Chamber Light Toggle */} + + {/* Camera Button */}