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
This commit is contained in:
maziggy
2026-01-11 08:47:13 +01:00
parent fa7296389b
commit 6f6df1e339
8 changed files with 502 additions and 380 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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<{
@@ -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
View File
@@ -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 one or more lines are too long
+1 -1
View File
@@ -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>