leLab / src /pages /Calibration.tsx
Nicolas Rabault
Fix and improve Calibration
137f4c1
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;