Spaces:
Running
Running
| import { useState, useEffect, useRef, useMemo } from "react"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { Alert, AlertDescription } from "@/components/ui/alert"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { | |
| ArrowLeft, | |
| Settings, | |
| Activity, | |
| CheckCircle, | |
| XCircle, | |
| AlertCircle, | |
| Loader2, | |
| Play, | |
| Square, | |
| Trash2, | |
| List, | |
| } from "lucide-react"; | |
| import { useToast } from "@/hooks/use-toast"; | |
| import Logo from "@/components/Logo"; | |
| import PortDetectionButton from "@/components/ui/PortDetectionButton"; | |
| import PortDetectionModal from "@/components/ui/PortDetectionModal"; | |
| import { useApi } from "@/contexts/ApiContext"; | |
| interface CalibrationStatus { | |
| calibration_active: boolean; | |
| status: string; // "idle", "connecting", "homing", "recording", "completed", "error", "stopping" | |
| device_type: string | null; | |
| error: string | null; | |
| message: string; | |
| step: number; // Current calibration step | |
| total_steps: number; // Total number of calibration steps | |
| current_positions: Record<string, number> | null; | |
| recorded_ranges: Record< | |
| string, | |
| { min: number; max: number; current: number } | |
| > | null; | |
| } | |
| interface CalibrationRequest { | |
| device_type: string; // "robot" or "teleop" | |
| port: string; | |
| config_file: string; | |
| } | |
| interface CalibrationConfig { | |
| name: string; | |
| filename: string; | |
| size: number; | |
| modified: number; | |
| } | |
| // ConfigsResponse interface removed since we're using text input | |
| const Calibration = () => { | |
| const navigate = useNavigate(); | |
| const { toast } = useToast(); | |
| const { baseUrl, fetchWithHeaders } = useApi(); | |
| // Ref for auto-scrolling console | |
| const consoleRef = useRef<HTMLDivElement>(null); | |
| // Form state | |
| const [deviceType, setDeviceType] = useState<string>("robot"); | |
| const [port, setPort] = useState<string>(""); | |
| const [configFile, setConfigFile] = useState<string>(""); | |
| // Config loading and management | |
| const [isLoadingConfigs, setIsLoadingConfigs] = useState(false); | |
| const [availableConfigs, setAvailableConfigs] = useState<CalibrationConfig[]>( | |
| [] | |
| ); | |
| // Port detection state | |
| const [showPortDetection, setShowPortDetection] = useState(false); | |
| const [detectionRobotType, setDetectionRobotType] = useState< | |
| "leader" | "follower" | |
| >("leader"); | |
| // Calibration state | |
| const [calibrationStatus, setCalibrationStatus] = useState<CalibrationStatus>( | |
| { | |
| calibration_active: false, | |
| status: "idle", | |
| device_type: null, | |
| error: null, | |
| message: "", | |
| step: 0, | |
| total_steps: 2, | |
| current_positions: null, | |
| recorded_ranges: null, | |
| } | |
| ); | |
| const [isPolling, setIsPolling] = useState(false); | |
| // Config loading removed since we're using text input now | |
| // Poll calibration status | |
| const pollStatus = async () => { | |
| try { | |
| const response = await fetchWithHeaders(`${baseUrl}/calibration-status`); | |
| if (response.ok) { | |
| const status = await response.json(); | |
| const previousStatus = calibrationStatus.status; | |
| // Debug logging | |
| console.log("Status update:", { | |
| previousStatus, | |
| newStatus: status.status, | |
| calibrationActive: status.calibration_active, | |
| polling: isPolling, | |
| }); | |
| setCalibrationStatus(status); | |
| // If calibration just completed successfully, refresh the configs list | |
| if ( | |
| previousStatus !== "completed" && | |
| status.status === "completed" && | |
| !status.calibration_active && | |
| deviceType | |
| ) { | |
| console.log("Calibration completed - refreshing available configs"); | |
| loadAvailableConfigs(deviceType); | |
| } | |
| // Stop polling if calibration is completed, error, or stopped (idle) | |
| if ( | |
| !status.calibration_active && | |
| (status.status === "completed" || | |
| status.status === "error" || | |
| status.status === "idle") | |
| ) { | |
| console.log("Stopping polling due to status:", status.status); | |
| setIsPolling(false); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error polling status:", error); | |
| } | |
| }; | |
| // Start calibration | |
| const handleStartCalibration = async () => { | |
| if (!deviceType || !port || !configFile) { | |
| toast({ | |
| title: "Missing Information", | |
| description: "Please fill in all required fields", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| const request: CalibrationRequest = { | |
| device_type: deviceType, | |
| port: port, | |
| config_file: configFile, | |
| }; | |
| try { | |
| const response = await fetchWithHeaders(`${baseUrl}/start-calibration`, { | |
| method: "POST", | |
| body: JSON.stringify(request), | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| toast({ | |
| title: "Calibration Started", | |
| description: `Calibration started for ${deviceType}`, | |
| }); | |
| setIsPolling(true); | |
| } else { | |
| toast({ | |
| title: "Calibration Failed", | |
| description: result.message || "Failed to start calibration", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Error starting calibration:", error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to start calibration", | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| // Stop calibration | |
| const handleStopCalibration = async () => { | |
| try { | |
| const response = await fetchWithHeaders(`${baseUrl}/stop-calibration`, { | |
| method: "POST", | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| toast({ | |
| title: "Calibration Stopped", | |
| description: "Calibration has been stopped", | |
| }); | |
| // Force a status check after stopping | |
| setTimeout(() => { | |
| pollStatus(); | |
| }, 500); | |
| } else { | |
| toast({ | |
| title: "Error", | |
| description: result.message || "Failed to stop calibration", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Error stopping calibration:", error); | |
| toast({ | |
| title: "Error", | |
| description: "Failed to stop calibration", | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| // Load available configs for the selected device type | |
| const loadAvailableConfigs = async (deviceType: string) => { | |
| if (!deviceType) return; | |
| setIsLoadingConfigs(true); | |
| try { | |
| const response = await fetchWithHeaders( | |
| `${baseUrl}/calibration-configs/${deviceType}` | |
| ); | |
| const data = await response.json(); | |
| if (data.success) { | |
| setAvailableConfigs(data.configs || []); | |
| } else { | |
| toast({ | |
| title: "Error Loading Configs", | |
| description: data.message || "Could not load calibration configs", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error Loading Configs", | |
| description: "Could not connect to the backend server", | |
| variant: "destructive", | |
| }); | |
| } finally { | |
| setIsLoadingConfigs(false); | |
| } | |
| }; | |
| // Delete a config file | |
| const handleDeleteConfig = async (configName: string) => { | |
| if (!deviceType) return; | |
| try { | |
| const response = await fetchWithHeaders( | |
| `${baseUrl}/calibration-configs/${deviceType}/${configName}`, | |
| { method: "DELETE" } | |
| ); | |
| const data = await response.json(); | |
| if (data.success) { | |
| toast({ | |
| title: "Config Deleted", | |
| description: data.message, | |
| }); | |
| // Reload the configs list | |
| loadAvailableConfigs(deviceType); | |
| } else { | |
| toast({ | |
| title: "Delete Failed", | |
| description: data.message || "Could not delete the configuration", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: "Could not delete the configuration", | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| // Complete current calibration step | |
| const handleCompleteStep = async () => { | |
| if (!calibrationStatus.calibration_active) return; | |
| try { | |
| const response = await fetchWithHeaders( | |
| `${baseUrl}/complete-calibration-step`, | |
| { | |
| method: "POST", | |
| } | |
| ); | |
| const data = await response.json(); | |
| if (data.success) { | |
| toast({ | |
| title: "Step Completed", | |
| description: data.message, | |
| }); | |
| } else { | |
| toast({ | |
| title: "Step Failed", | |
| description: data.message || "Could not complete step", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Error completing step:", error); | |
| toast({ | |
| title: "Error", | |
| description: "Could not complete calibration step", | |
| variant: "destructive", | |
| }); | |
| } | |
| }; | |
| // Config loading removed - using text input instead | |
| // Set up polling | |
| useEffect(() => { | |
| let interval: NodeJS.Timeout; | |
| if (isPolling) { | |
| // Use fast polling during active calibration for real-time updates | |
| const pollInterval = calibrationStatus.calibration_active ? 100 : 200; | |
| interval = setInterval(pollStatus, pollInterval); | |
| pollStatus(); // Initial poll | |
| } | |
| return () => { | |
| if (interval) clearInterval(interval); | |
| }; | |
| }, [isPolling, calibrationStatus.calibration_active]); | |
| // Load configs when device type changes | |
| useEffect(() => { | |
| if (deviceType) { | |
| loadAvailableConfigs(deviceType); | |
| } else { | |
| setAvailableConfigs([]); | |
| } | |
| }, [deviceType]); | |
| // Load default port when device type changes | |
| useEffect(() => { | |
| const loadDefaultPort = async () => { | |
| if (!deviceType) return; | |
| try { | |
| const robotType = deviceType === "robot" ? "follower" : "leader"; | |
| const response = await fetchWithHeaders( | |
| `${baseUrl}/robot-port/${robotType}` | |
| ); | |
| const data = await response.json(); | |
| if (data.status === "success") { | |
| // Use saved port if available, otherwise use default port | |
| const portToUse = data.saved_port || data.default_port; | |
| if (portToUse) { | |
| setPort(portToUse); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error loading default port:", error); | |
| } | |
| }; | |
| loadDefaultPort(); | |
| }, [deviceType]); | |
| // Handle port detection | |
| const handlePortDetection = () => { | |
| const robotType = deviceType === "robot" ? "follower" : "leader"; | |
| setDetectionRobotType(robotType); | |
| setShowPortDetection(true); | |
| }; | |
| const handlePortDetected = (detectedPort: string) => { | |
| setPort(detectedPort); | |
| }; | |
| // Get status color and icon | |
| const getStatusDisplay = () => { | |
| switch (calibrationStatus.status) { | |
| case "idle": | |
| return { | |
| color: "bg-slate-500", | |
| icon: <Settings className="w-4 h-4" />, | |
| text: "Idle", | |
| }; | |
| case "connecting": | |
| return { | |
| color: "bg-yellow-500", | |
| icon: <Loader2 className="w-4 h-4 animate-spin" />, | |
| text: "Connecting", | |
| }; | |
| case "homing": | |
| return { | |
| color: "bg-blue-500", | |
| icon: <Activity className="w-4 h-4" />, | |
| text: "Setting Home Position", | |
| }; | |
| case "recording": | |
| return { | |
| color: "bg-purple-500", | |
| icon: <Activity className="w-4 h-4" />, | |
| text: "Recording Ranges", | |
| }; | |
| case "completed": | |
| return { | |
| color: "bg-green-500", | |
| icon: <CheckCircle className="w-4 h-4" />, | |
| text: "Completed", | |
| }; | |
| case "error": | |
| return { | |
| color: "bg-red-500", | |
| icon: <XCircle className="w-4 h-4" />, | |
| text: "Error", | |
| }; | |
| case "stopping": | |
| return { | |
| color: "bg-orange-500", | |
| icon: <Square className="w-4 h-4" />, | |
| text: "Stopping", | |
| }; | |
| default: | |
| return { | |
| color: "bg-slate-500", | |
| icon: <Settings className="w-4 h-4" />, | |
| text: "Unknown", | |
| }; | |
| } | |
| }; | |
| const statusDisplay = getStatusDisplay(); | |
| return ( | |
| <div className="min-h-screen bg-slate-900 text-white p-4"> | |
| <div className="max-w-4xl mx-auto"> | |
| {/* Header */} | |
| <div className="flex items-center gap-4 mb-6"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={() => navigate(-1)} | |
| className="text-slate-400 hover:text-white hover:bg-slate-800" | |
| > | |
| <ArrowLeft className="w-5 h-5" /> | |
| </Button> | |
| <div className="flex items-center gap-3"> | |
| <Logo iconOnly /> | |
| <h1 className="text-3xl font-bold">Device Calibration</h1> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> | |
| {/* Configuration Panel */} | |
| <Card className="bg-slate-800/60 border-slate-700 backdrop-blur-sm"> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2 text-slate-200"> | |
| <Settings className="w-5 h-5 text-blue-400" /> | |
| Configuration | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-6"> | |
| {/* Device Type Selection */} | |
| <div className="space-y-2"> | |
| <Label | |
| htmlFor="deviceType" | |
| className="text-sm font-medium text-slate-300" | |
| > | |
| Device Type * | |
| </Label> | |
| <Select value={deviceType} onValueChange={setDeviceType}> | |
| <SelectTrigger className="bg-slate-700 border-slate-600 text-white rounded-md"> | |
| <SelectValue placeholder="Select device type" /> | |
| </SelectTrigger> | |
| <SelectContent className="bg-slate-800 border-slate-700 text-white"> | |
| <SelectItem value="robot" className="hover:bg-slate-700"> | |
| Robot (Follower) | |
| </SelectItem> | |
| <SelectItem value="teleop" className="hover:bg-slate-700"> | |
| Teleoperator (Leader) | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| {/* Port Configuration */} | |
| <div className="space-y-2"> | |
| <Label | |
| htmlFor="port" | |
| className="text-sm font-medium text-slate-300" | |
| > | |
| Port * | |
| </Label> | |
| <div className="flex gap-2"> | |
| <Input | |
| id="port" | |
| value={port} | |
| onChange={(e) => setPort(e.target.value)} | |
| placeholder="/dev/tty.usbmodem..." | |
| className="bg-slate-700 border-slate-600 text-white rounded-md flex-1" | |
| /> | |
| <PortDetectionButton | |
| onClick={handlePortDetection} | |
| robotType={deviceType === "robot" ? "follower" : "leader"} | |
| className="border-slate-600 hover:border-blue-500 text-slate-400 hover:text-blue-400 bg-slate-700 hover:bg-slate-600" | |
| /> | |
| </div> | |
| </div> | |
| {/* Config File Name */} | |
| <div className="space-y-2"> | |
| <Label | |
| htmlFor="configFile" | |
| className="text-sm font-medium text-slate-300" | |
| > | |
| Calibration Config * | |
| </Label> | |
| <Input | |
| id="configFile" | |
| value={configFile} | |
| onChange={(e) => setConfigFile(e.target.value)} | |
| placeholder="config_name (e.g., my_robot_v1)" | |
| className="bg-slate-700 border-slate-600 text-white rounded-md" | |
| /> | |
| </div> | |
| {/* Available Configurations List */} | |
| {deviceType && ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2"> | |
| <List className="w-4 h-4 text-slate-400" /> | |
| <Label className="text-sm font-medium text-slate-300"> | |
| Available Configurations | |
| </Label> | |
| {isLoadingConfigs && ( | |
| <Loader2 className="w-4 h-4 animate-spin text-slate-400" /> | |
| )} | |
| </div> | |
| <div className="max-h-40 overflow-y-auto bg-slate-900/50 rounded-lg border border-slate-700"> | |
| {availableConfigs.length === 0 ? ( | |
| <div className="p-3 text-center text-slate-400 text-sm"> | |
| {isLoadingConfigs | |
| ? "Loading..." | |
| : "No configurations found"} | |
| </div> | |
| ) : ( | |
| <div className="space-y-1 p-2"> | |
| {availableConfigs.map((config) => ( | |
| <div | |
| key={config.name} | |
| className="flex items-center justify-between bg-slate-700/50 rounded-md px-3 py-2 hover:bg-slate-700 transition-colors" | |
| > | |
| <div className="flex-1 min-w-0"> | |
| <button | |
| onClick={() => setConfigFile(config.name)} | |
| className="text-left w-full text-white hover:text-blue-300 font-medium truncate" | |
| title={`Click to select: ${config.name}`} | |
| > | |
| {config.name} | |
| </button> | |
| <div className="text-xs text-slate-400"> | |
| {new Date( | |
| config.modified * 1000 | |
| ).toLocaleDateString()} | |
| {" • "} | |
| {(config.size / 1024).toFixed(1)} KB | |
| </div> | |
| </div> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDeleteConfig(config.name); | |
| }} | |
| className="ml-3 p-1 text-red-500/80 hover:text-red-500 hover:bg-red-500/10 rounded-full transition-colors" | |
| title={`Delete ${config.name}`} | |
| > | |
| <Trash2 className="w-4 h-4" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| <Separator className="bg-slate-700" /> | |
| {/* Action Buttons */} | |
| <div className="flex flex-col gap-3"> | |
| {!calibrationStatus.calibration_active ? ( | |
| <Button | |
| onClick={handleStartCalibration} | |
| className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded-full py-6 text-lg" | |
| disabled={ | |
| isLoadingConfigs || !deviceType || !port || !configFile | |
| } | |
| > | |
| <Play className="w-5 h-5 mr-2" /> | |
| Start Calibration | |
| </Button> | |
| ) : ( | |
| <Button | |
| onClick={handleStopCalibration} | |
| variant="destructive" | |
| className="w-full rounded-full py-6 text-lg" | |
| > | |
| <Square className="w-5 h-5 mr-2" /> | |
| Stop Calibration | |
| </Button> | |
| )} | |
| </div> | |
| </CardContent> | |
| </Card> | |
| {/* Status Panel */} | |
| <Card className="bg-slate-800/60 border-slate-700 backdrop-blur-sm"> | |
| <CardHeader> | |
| <CardTitle className="flex items-center gap-2 text-slate-200"> | |
| <Activity className="w-5 h-5 text-teal-400" /> | |
| Status | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| {/* Current Status */} | |
| <div className="flex items-center justify-between p-3 bg-slate-900/50 rounded-md"> | |
| <span className="text-slate-300">Status:</span> | |
| <Badge | |
| className={`${statusDisplay.color} text-white rounded-md`} | |
| > | |
| {statusDisplay.icon} | |
| <span className="ml-2">{statusDisplay.text}</span> | |
| </Badge> | |
| </div> | |
| {/* Live Position Data (during recording) */} | |
| {calibrationStatus.status === "recording" && | |
| calibrationStatus.recorded_ranges && ( | |
| <div className="space-y-3"> | |
| <div className="flex items-center gap-2"> | |
| <Activity className="w-4 h-4 text-purple-400" /> | |
| <span className="text-sm font-medium text-slate-300"> | |
| Live Position Data | |
| </span> | |
| </div> | |
| <div className="bg-slate-800 rounded-lg p-4 border border-slate-700"> | |
| <div className="space-y-3"> | |
| {Object.entries(calibrationStatus.recorded_ranges).map( | |
| ([motor, range]) => { | |
| // Calculate progress percentage (current position relative to min/max range) | |
| const totalRange = range.max - range.min; | |
| const currentOffset = range.current - range.min; | |
| const progressPercent = | |
| totalRange > 0 | |
| ? (currentOffset / totalRange) * 100 | |
| : 50; | |
| return ( | |
| <div key={motor} className="space-y-2"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-white font-semibold text-sm"> | |
| {motor} | |
| </span> | |
| <span className="text-slate-300 text-xs font-mono"> | |
| {range.current} | |
| </span> | |
| </div> | |
| <div className="relative"> | |
| {/* Progress bar background */} | |
| <div className="w-full bg-slate-700 rounded-full h-3"> | |
| {/* Min/Max range bar */} | |
| <div | |
| className="bg-slate-600 h-3 rounded-full relative" | |
| style={{ width: "100%" }} | |
| > | |
| {/* Current position indicator */} | |
| <div | |
| className="absolute top-0 w-1 h-3 bg-yellow-400 rounded-full transition-all duration-100" | |
| style={{ | |
| left: `${Math.max( | |
| 0, | |
| Math.min(100, progressPercent) | |
| )}%`, | |
| transform: "translateX(-50%)", | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| {/* Min/Max labels */} | |
| <div className="flex justify-between text-xs text-slate-400 mt-1"> | |
| <span>{range.min}</span> | |
| <span>{range.max}</span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* Status Messages */} | |
| {calibrationStatus.status === "connecting" && ( | |
| <Alert className="bg-yellow-900/50 border-yellow-700 text-yellow-200"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertDescription> | |
| Connecting to the device. Please ensure it's connected. | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| {calibrationStatus.status === "homing" && ( | |
| <div className="space-y-3"> | |
| <Alert className="bg-blue-900/50 border-blue-700 text-blue-200"> | |
| <Activity className="h-4 w-4" /> | |
| <AlertDescription> | |
| Move the device to the middle position of its range, then | |
| click "Ready". | |
| </AlertDescription> | |
| </Alert> | |
| <div className="flex justify-center"> | |
| <Button | |
| onClick={handleCompleteStep} | |
| disabled={!calibrationStatus.calibration_active} | |
| className="bg-green-600 hover:bg-green-700 px-8 py-3 rounded-full" | |
| > | |
| <CheckCircle className="w-4 h-4 mr-2" /> | |
| Ready | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {calibrationStatus.status === "recording" && ( | |
| <div className="space-y-3"> | |
| <Alert className="bg-purple-900/50 border-purple-700 text-purple-200"> | |
| <Activity className="h-4 w-4" /> | |
| <AlertDescription> | |
| <strong>Important:</strong> Move EACH joint from its | |
| minimum to maximum position to record full range. Watch | |
| the min/max values change in the live data above. Ensure | |
| all joints have significant range before finishing. | |
| </AlertDescription> | |
| </Alert> | |
| <div className="flex justify-center"> | |
| <Button | |
| onClick={handleCompleteStep} | |
| disabled={!calibrationStatus.calibration_active} | |
| className="bg-green-600 hover:bg-green-700 px-8 py-3 rounded-full" | |
| > | |
| <CheckCircle className="w-4 h-4 mr-2" /> | |
| Calibration End | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {calibrationStatus.status === "completed" && ( | |
| <Alert className="bg-green-900/50 border-green-700 text-green-200"> | |
| <CheckCircle className="h-4 w-4" /> | |
| <AlertDescription> | |
| Calibration completed successfully! | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| {calibrationStatus.status === "error" && | |
| calibrationStatus.error && ( | |
| <Alert className="bg-red-900/50 border-red-700 text-red-200"> | |
| <XCircle className="h-4 w-4" /> | |
| <AlertDescription> | |
| <strong>Error:</strong> {calibrationStatus.error} | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| {/* Calibration Video */} | |
| <div className="bg-slate-900/50 p-4 rounded-lg border border-slate-700"> | |
| <h4 className="font-semibold mb-3 text-slate-200"> | |
| Calibration Demo: | |
| </h4> | |
| <div className="relative rounded-lg overflow-hidden bg-slate-800"> | |
| <video | |
| className="w-full h-auto rounded-md" | |
| controls | |
| preload="auto" | |
| muted | |
| > | |
| <source | |
| src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/calibrate_so101_2.mp4" | |
| type="video/mp4" | |
| /> | |
| <p className="text-slate-400 text-sm text-center py-4"> | |
| Your browser does not support the video tag. | |
| <br /> | |
| <a | |
| href="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/calibrate_so101_2.mp4" | |
| className="text-blue-400 hover:text-blue-300 underline" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| > | |
| Click here to view the calibration video | |
| </a> | |
| </p> | |
| </video> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| <PortDetectionModal | |
| open={showPortDetection} | |
| onOpenChange={setShowPortDetection} | |
| robotType={detectionRobotType} | |
| onPortDetected={handlePortDetected} | |
| /> | |
| </div> | |
| ); | |
| }; | |
| export default Calibration; | |