mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-22 03:49:18 +02:00
Wired up top status icons and video settings
This commit is contained in:
@@ -113,6 +113,10 @@ class LightRequest(BaseModel):
|
||||
on: bool = Field(..., description="Light state: true=on, false=off")
|
||||
|
||||
|
||||
class CameraSettingRequest(BaseModel):
|
||||
enable: bool = Field(..., description="Enable or disable the setting")
|
||||
|
||||
|
||||
class HomeRequest(ConfirmableRequest):
|
||||
axes: str = Field(default="XYZ", description="Axes to home (e.g., 'XYZ', 'X', 'XY', 'Z')")
|
||||
|
||||
@@ -553,3 +557,41 @@ async def send_gcode(
|
||||
success=success,
|
||||
message="G-code sent" if success else "Failed to send G-code"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Camera Settings Endpoints
|
||||
# =============================================================================
|
||||
|
||||
@router.post("/{printer_id}/control/camera/timelapse", response_model=ControlResponse)
|
||||
async def set_timelapse(
|
||||
printer_id: int,
|
||||
request: CameraSettingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enable or disable timelapse recording."""
|
||||
await get_printer_or_404(printer_id, db)
|
||||
client = get_mqtt_client_or_503(printer_id)
|
||||
|
||||
success = client.set_timelapse(request.enable)
|
||||
return ControlResponse(
|
||||
success=success,
|
||||
message=f"Timelapse {'enabled' if request.enable else 'disabled'}" if success else "Failed to set timelapse"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{printer_id}/control/camera/liveview", response_model=ControlResponse)
|
||||
async def set_liveview(
|
||||
printer_id: int,
|
||||
request: CameraSettingRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Enable or disable live view / camera streaming."""
|
||||
await get_printer_or_404(printer_id, db)
|
||||
client = get_mqtt_client_or_503(printer_id)
|
||||
|
||||
success = client.set_liveview(request.enable)
|
||||
return ControlResponse(
|
||||
success=success,
|
||||
message=f"Live view {'enabled' if request.enable else 'disabled'}" if success else "Failed to set live view"
|
||||
)
|
||||
|
||||
@@ -201,6 +201,9 @@ async def get_printer_status(printer_id: int, db: AsyncSession = Depends(get_db)
|
||||
ams=ams_units,
|
||||
ams_exists=ams_exists,
|
||||
vt_tray=vt_tray,
|
||||
sdcard=state.sdcard,
|
||||
timelapse=state.timelapse,
|
||||
ipcam=state.ipcam,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -76,3 +76,6 @@ class PrinterStatus(BaseModel):
|
||||
ams: list[AMSUnit] = []
|
||||
ams_exists: bool = False
|
||||
vt_tray: AMSTray | None = None # Virtual tray / external spool
|
||||
sdcard: bool = False # SD card inserted
|
||||
timelapse: bool = False # Timelapse recording active
|
||||
ipcam: bool = False # Live view enabled
|
||||
|
||||
@@ -63,6 +63,9 @@ class PrinterState:
|
||||
subtask_id: str | None = None
|
||||
hms_errors: list = field(default_factory=list) # List of HMSError
|
||||
kprofiles: list = field(default_factory=list) # List of KProfile
|
||||
sdcard: bool = False # SD card inserted
|
||||
timelapse: bool = False # Timelapse recording active
|
||||
ipcam: bool = False # Live view / camera streaming enabled
|
||||
|
||||
|
||||
class BambuMQTTClient:
|
||||
@@ -170,6 +173,16 @@ class BambuMQTTClient:
|
||||
except Exception as e:
|
||||
logger.error(f"[{self.serial_number}] Error handling AMS data: {e}")
|
||||
|
||||
# Handle xcam data (camera settings) at top level
|
||||
if "xcam" in payload:
|
||||
xcam_data = payload["xcam"]
|
||||
logger.info(f"[{self.serial_number}] Received xcam data: {xcam_data}")
|
||||
if isinstance(xcam_data, dict):
|
||||
if "ipcam_record" in xcam_data:
|
||||
self.state.ipcam = xcam_data.get("ipcam_record") == "enable"
|
||||
if "timelapse" in xcam_data:
|
||||
self.state.timelapse = xcam_data.get("timelapse") == "enable"
|
||||
|
||||
if "print" in payload:
|
||||
print_data = payload["print"]
|
||||
# Log when we see gcode_state changes
|
||||
@@ -324,6 +337,25 @@ class BambuMQTTClient:
|
||||
severity=severity if severity > 0 else 3,
|
||||
))
|
||||
|
||||
# Parse SD card status
|
||||
if "sdcard" in data:
|
||||
self.state.sdcard = data["sdcard"] is True
|
||||
|
||||
# Parse timelapse status (recording active during print)
|
||||
if "timelapse" in data:
|
||||
logger.debug(f"[{self.serial_number}] timelapse field: {data['timelapse']}")
|
||||
self.state.timelapse = data["timelapse"] is True
|
||||
|
||||
# Parse ipcam/live view status
|
||||
if "ipcam" in data:
|
||||
ipcam_data = data["ipcam"]
|
||||
logger.debug(f"[{self.serial_number}] ipcam field: {ipcam_data}")
|
||||
if isinstance(ipcam_data, dict):
|
||||
# Check ipcam_record field for live view status
|
||||
self.state.ipcam = ipcam_data.get("ipcam_record") == "enable"
|
||||
else:
|
||||
self.state.ipcam = ipcam_data is True
|
||||
|
||||
# Preserve AMS and vt_tray data when updating raw_data
|
||||
ams_data = self.state.raw_data.get("ams")
|
||||
vt_tray_data = self.state.raw_data.get("vt_tray")
|
||||
@@ -1165,3 +1197,68 @@ class BambuMQTTClient:
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
logger.info(f"[{self.serial_number}] AMS control: {action}")
|
||||
return True
|
||||
|
||||
def set_timelapse(self, enable: bool) -> bool:
|
||||
"""Enable or disable timelapse recording.
|
||||
|
||||
Args:
|
||||
enable: True to enable, False to disable
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot set timelapse: not connected")
|
||||
return False
|
||||
|
||||
command = {
|
||||
"pushing": {
|
||||
"command": "pushall",
|
||||
"sequence_id": "0"
|
||||
}
|
||||
}
|
||||
# First send the timelapse setting
|
||||
timelapse_cmd = {
|
||||
"print": {
|
||||
"command": "gcode_line",
|
||||
"param": f"M981 S{1 if enable else 0} P20000",
|
||||
"sequence_id": "0"
|
||||
}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(timelapse_cmd))
|
||||
# Request status update
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
logger.info(f"[{self.serial_number}] Set timelapse {'enabled' if enable else 'disabled'}")
|
||||
return True
|
||||
|
||||
def set_liveview(self, enable: bool) -> bool:
|
||||
"""Enable or disable live view / camera streaming.
|
||||
|
||||
Args:
|
||||
enable: True to enable, False to disable
|
||||
|
||||
Returns:
|
||||
True if command was sent, False otherwise
|
||||
"""
|
||||
if not self._client or not self.state.connected:
|
||||
logger.warning(f"[{self.serial_number}] Cannot set liveview: not connected")
|
||||
return False
|
||||
|
||||
command = {
|
||||
"xcam": {
|
||||
"command": "ipcam_record_set",
|
||||
"control": "enable" if enable else "disable",
|
||||
"sequence_id": "0"
|
||||
}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(command))
|
||||
# Request status update
|
||||
pushall = {
|
||||
"pushing": {
|
||||
"command": "pushall",
|
||||
"sequence_id": "0"
|
||||
}
|
||||
}
|
||||
self._client.publish(self.topic_publish, json.dumps(pushall))
|
||||
logger.info(f"[{self.serial_number}] Set liveview {'enabled' if enable else 'disabled'}")
|
||||
return True
|
||||
|
||||
@@ -80,6 +80,9 @@ export interface PrinterStatus {
|
||||
ams: AMSUnit[];
|
||||
ams_exists: boolean;
|
||||
vt_tray: AMSTray | null; // Virtual tray / external spool
|
||||
sdcard: boolean; // SD card inserted
|
||||
timelapse: boolean; // Timelapse recording active
|
||||
ipcam: boolean; // Live view enabled
|
||||
}
|
||||
|
||||
export interface PrinterCreate {
|
||||
@@ -1163,4 +1166,14 @@ export const api = {
|
||||
`${API_BASE}/printers/${printerId}/camera/snapshot`,
|
||||
testCameraConnection: (printerId: number) =>
|
||||
request<{ success: boolean; message?: string; error?: string }>(`/printers/${printerId}/camera/test`),
|
||||
setTimelapse: (printerId: number, enable: boolean) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/camera/timelapse`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enable }),
|
||||
}),
|
||||
setLiveview: (printerId: number, enable: boolean) =>
|
||||
request<ControlResponse>(`/printers/${printerId}/control/camera/liveview`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enable }),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import type { PrinterStatus } from '../../api/client';
|
||||
import { X, Loader2 } from 'lucide-react';
|
||||
import { Card, CardContent } from '../Card';
|
||||
|
||||
interface CameraSettingsModalProps {
|
||||
printerId: number;
|
||||
status: PrinterStatus | null | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CameraSettingsModal({ printerId, status, onClose }: CameraSettingsModalProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isConnected = status?.connected ?? false;
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const timelapseMutation = useMutation({
|
||||
mutationFn: (enable: boolean) => api.setTimelapse(printerId, enable),
|
||||
onSuccess: (data) => {
|
||||
console.log('Timelapse mutation success:', data);
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Timelapse mutation error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const liveviewMutation = useMutation({
|
||||
mutationFn: (enable: boolean) => api.setLiveview(printerId, enable),
|
||||
onSuccess: (data) => {
|
||||
console.log('Liveview mutation success:', data);
|
||||
queryClient.invalidateQueries({ queryKey: ['printerStatuses'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Liveview mutation error:', error);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('CameraSettingsModal render - isConnected:', isConnected, 'status:', status);
|
||||
|
||||
const handleTimelapseToggle = () => {
|
||||
console.log('Timelapse toggle clicked, current:', status?.timelapse, 'setting to:', !status?.timelapse);
|
||||
timelapseMutation.mutate(!status?.timelapse);
|
||||
};
|
||||
|
||||
const handleLiveviewToggle = () => {
|
||||
console.log('Liveview toggle clicked, current:', status?.ipcam, 'setting to:', !status?.ipcam);
|
||||
liveviewMutation.mutate(!status?.ipcam);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Card className="w-full max-w-sm" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent className="p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-bambu-dark-tertiary">
|
||||
<span className="text-sm font-medium text-white">Camera Settings</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Auto-record Monitoring (ipcam_record - records to SD during print) */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white">Auto-record Monitoring</span>
|
||||
<button
|
||||
onClick={handleLiveviewToggle}
|
||||
disabled={!isConnected || liveviewMutation.isPending}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
status?.ipcam ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{liveviewMutation.isPending ? (
|
||||
<Loader2 className="absolute inset-0 m-auto w-4 h-4 animate-spin text-white" />
|
||||
) : (
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
status?.ipcam ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Go Live (timelapse - live streaming) */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-white">Go Live</span>
|
||||
<button
|
||||
onClick={handleTimelapseToggle}
|
||||
disabled={!isConnected || timelapseMutation.isPending}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
status?.timelapse ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
{timelapseMutation.isPending ? (
|
||||
<Loader2 className="absolute inset-0 m-auto w-4 h-4 animate-spin text-white" />
|
||||
) : (
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
status?.timelapse ? 'left-7' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -102,6 +102,12 @@ body {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Green-colored icon for active status indicators */
|
||||
.icon-green {
|
||||
filter: invert(48%) sepia(89%) saturate(459%) hue-rotate(93deg) brightness(95%) contrast(92%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Jogpad theme styling - darken background in dark mode */
|
||||
.jogpad-theme {
|
||||
/* Light mode - normal */
|
||||
|
||||
@@ -10,11 +10,13 @@ import { JogPad } from '../components/control/JogPad';
|
||||
import { BedControls } from '../components/control/BedControls';
|
||||
import { ExtruderControls } from '../components/control/ExtruderControls';
|
||||
import { AMSSectionDual } from '../components/control/AMSSectionDual';
|
||||
import { CameraSettingsModal } from '../components/control/CameraSettingsModal';
|
||||
import { Loader2, WifiOff, Video, Webcam, Settings } from 'lucide-react';
|
||||
|
||||
export function ControlPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selectedPrinterId, setSelectedPrinterId] = useState<number | null>(null);
|
||||
const [showCameraSettings, setShowCameraSettings] = useState(false);
|
||||
|
||||
// Fetch all printers
|
||||
const { data: printers, isLoading: loadingPrinters } = useQuery({
|
||||
@@ -91,9 +93,8 @@ export function ControlPage() {
|
||||
{/* Printer Tabs */}
|
||||
<div className="bg-bambu-dark-secondary border-b border-bambu-dark-tertiary">
|
||||
<div className="flex overflow-x-auto">
|
||||
{printers.map((printer) => {
|
||||
{printers.filter((p) => statuses?.[p.id]?.connected).map((printer) => {
|
||||
const status = statuses?.[printer.id];
|
||||
const isConnected = status?.connected ?? false;
|
||||
const isSelected = printer.id === selectedPrinterId;
|
||||
|
||||
return (
|
||||
@@ -106,11 +107,7 @@ export function ControlPage() {
|
||||
: 'border-transparent text-bambu-gray hover:text-white hover:bg-bambu-dark-tertiary'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
isConnected ? 'bg-bambu-green' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="w-2 h-2 rounded-full bg-bambu-green" />
|
||||
{printer.name}
|
||||
{status?.state && status.state !== 'IDLE' && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-bambu-dark-tertiary">
|
||||
@@ -130,16 +127,19 @@ export function ControlPage() {
|
||||
<div className="flex-1 flex flex-col bg-bambu-dark">
|
||||
{/* Camera Header Icons - same height as Control header */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 py-2.5 bg-bambu-dark-secondary border-b border-bambu-dark-tertiary min-h-[44px]">
|
||||
<button className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white">
|
||||
<img src="/icons/micro-sd.svg" alt="SD Card" className="w-4 h-4 icon-theme" />
|
||||
<button className={`p-1.5 rounded hover:bg-bambu-dark-tertiary ${selectedStatus?.sdcard ? 'text-bambu-green' : 'text-bambu-gray hover:text-white'}`}>
|
||||
<img src="/icons/micro-sd.svg" alt="SD Card" className={`w-4 h-4 ${selectedStatus?.sdcard ? 'icon-green' : 'icon-theme'}`} />
|
||||
</button>
|
||||
<button className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white">
|
||||
<button className={`p-1.5 rounded hover:bg-bambu-dark-tertiary ${selectedStatus?.timelapse ? 'text-red-500' : 'text-bambu-gray hover:text-white'}`}>
|
||||
<Video className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white">
|
||||
<button className={`p-1.5 rounded hover:bg-bambu-dark-tertiary ${selectedStatus?.ipcam ? 'text-bambu-green' : 'text-bambu-gray hover:text-white'}`}>
|
||||
<Webcam className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white">
|
||||
<button
|
||||
onClick={() => setShowCameraSettings(true)}
|
||||
className="p-1.5 rounded hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -238,6 +238,15 @@ export function ControlPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Camera Settings Modal */}
|
||||
{showCameraSettings && selectedPrinter && (
|
||||
<CameraSettingsModal
|
||||
printerId={selectedPrinter.id}
|
||||
status={selectedStatus}
|
||||
onClose={() => setShowCameraSettings(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Dg2N9nGX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Da3EoG5B.css">
|
||||
<script type="module" crossorigin src="/assets/index-CHgTUeZT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DaEsKk03.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Reference in New Issue
Block a user