Browse Source

Wired up top status icons and video settings

Martin Ziegler 5 months ago
parent
commit
42458974d8

+ 42 - 0
backend/app/api/routes/printer_control.py

@@ -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 - 0
backend/app/api/routes/printers.py

@@ -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 - 0
backend/app/schemas/printer.py

@@ -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 - 0
backend/app/services/bambu_mqtt.py

@@ -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 - 0
frontend/src/api/client.ts

@@ -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 }),
+    }),
 };

+ 129 - 0
frontend/src/components/control/CameraSettingsModal.tsx

@@ -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 - 0
frontend/src/index.css

@@ -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
frontend/src/pages/ControlPage.tsx

@@ -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 it is too large
+ 0 - 0
static/assets/index-CHgTUeZT.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Da3EoG5B.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DaEsKk03.css


+ 2 - 2
static/index.html

@@ -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>

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