Wired up top status icons and video settings

This commit is contained in:
Martin Ziegler
2025-12-02 17:13:27 +01:00
parent 863dedf6f4
commit 42458974d8
12 changed files with 339 additions and 37 deletions
+42
View File
@@ -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"
)
+3
View File
@@ -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,
)
+3
View File
@@ -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
+97
View File
@@ -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
+13
View File
@@ -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>
);
}
+6
View File
@@ -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 */
+21 -12
View File
@@ -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
View File
@@ -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>