import { useState, useEffect, useRef, useCallback } from "react"; import { useNavigate, useLocation } 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 { Switch } from "@/components/ui/switch"; import { ArrowLeft, Settings, Activity, CheckCircle, XCircle, AlertCircle, Loader2, Play, Square, Circle, Camera, ShieldQuestion, } 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"; import { isMotorRangeComplete } from "@/lib/calibrationTargets"; import CameraConfiguration, { CameraConfig, } from "@/components/recording/CameraConfiguration"; const DISCONTINUITY_ERROR_PREFIX = "Motor discontinuity detected"; interface CalibrationStatus { calibration_active: boolean; status: string; // "idle", "connecting", "recording", "completed", "error", "stopping" device_type: string | null; error: string | null; message: string; step: number; total_steps: number; current_positions: Record | 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; robot_name: string | null; } interface RobotRecord { name: string; leader_port: string; follower_port: string; leader_config: string; follower_config: string; cameras: CameraConfig[]; is_clean: boolean; } const Calibration = () => { const navigate = useNavigate(); const location = useLocation(); const robotName = (location.state as { robot_name?: string } | null)?.robot_name ?? null; const { toast } = useToast(); const { baseUrl, fetchWithHeaders } = useApi(); const consoleRef = useRef(null); const demoVideoRef = useRef(null); const [deviceType, setDeviceType] = useState("teleop"); const [port, setPort] = useState(""); const [robot, setRobot] = useState(null); const [cameras, setCameras] = useState([]); // Off by default so merely opening the calibration page never grabs a camera. // The user explicitly starts a scan, which is when cameras are turned on, // enumerated, and the browser permission prompt is requested. const [camerasActive, setCamerasActive] = useState(false); const cameraSaveTimerRef = useRef(null); const fetchRobot = useCallback(async (): Promise => { if (!robotName) return null; try { const res = await fetchWithHeaders( `${baseUrl}/robots/${encodeURIComponent(robotName)}` ); if (!res.ok) return null; const data = await res.json(); const r = (data.robot as RobotRecord | null) ?? null; setRobot(r); return r; } catch (e) { console.error("Failed to load robot record:", e); return null; } }, [robotName, baseUrl, fetchWithHeaders]); // Initial fetch + form prefill on arrival. useEffect(() => { if (!robotName) return; let cancelled = false; (async () => { const r = await fetchRobot(); if (!r || cancelled) return; // Default to the first incomplete side in the checklist (leader, then follower). const defaultDevice = !r.leader_config ? "teleop" : !r.follower_config ? "robot" : "teleop"; setDeviceType(defaultDevice); setPort( defaultDevice === "teleop" ? r.leader_port || "" : r.follower_port || "" ); setCameras(r.cameras ?? []); })(); return () => { cancelled = true; }; }, [robotName, fetchRobot]); // Persist camera changes back to the robot record (debounced). const handleCamerasChange = (next: CameraConfig[]) => { setCameras(next); if (!robotName) return; if (cameraSaveTimerRef.current) { clearTimeout(cameraSaveTimerRef.current); } cameraSaveTimerRef.current = setTimeout(async () => { try { await fetchWithHeaders( `${baseUrl}/robots/${encodeURIComponent(robotName)}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cameras: next }), } ); } catch (e) { console.error("Failed to save cameras to robot record:", e); } }, 500); }; useEffect(() => { return () => { if (cameraSaveTimerRef.current) { clearTimeout(cameraSaveTimerRef.current); } }; }, []); const [showPortDetection, setShowPortDetection] = useState(false); const [detectionRobotType, setDetectionRobotType] = useState< "leader" | "follower" >("leader"); const [calibrationStatus, setCalibrationStatus] = useState( { calibration_active: false, status: "idle", device_type: null, error: null, message: "", step: 0, total_steps: 1, current_positions: null, recorded_ranges: null, } ); const [isPolling, setIsPolling] = useState(false); // Mirror calibration_active into a ref so the unmount cleanup below can read // the latest value without re-firing on every status change. const calibrationActiveRef = useRef(false); useEffect(() => { calibrationActiveRef.current = calibrationStatus.calibration_active; }, [calibrationStatus.calibration_active]); // If the user leaves this page (back arrow, browser back, programmatic nav) // while calibration is running, the backend singleton stays active and the // next Start request fails with "Calibration already active". Stop it on // unmount as a catch-all. useEffect(() => { return () => { if (calibrationActiveRef.current) { fetchWithHeaders(`${baseUrl}/stop-calibration`, { method: "POST" }).catch( (e) => console.error("Failed to stop calibration on unmount:", e) ); } }; }, [baseUrl, fetchWithHeaders]); const pollStatus = async () => { try { const response = await fetchWithHeaders(`${baseUrl}/calibration-status`); if (response.ok) { const status = await response.json(); setCalibrationStatus(status); if ( !status.calibration_active && (status.status === "completed" || status.status === "error" || status.status === "idle") ) { setIsPolling(false); } } } catch (error) { console.error("Error polling status:", error); } }; const handleStartCalibration = async () => { if (!robotName) { toast({ title: "No robot selected", description: "Open Calibration from a robot's gear icon on the Landing page.", variant: "destructive", }); return; } if (!port) { toast({ title: "Missing port", description: "Set the device's serial port before starting.", variant: "destructive", }); return; } const request: CalibrationRequest = { device_type: deviceType, port: port, config_file: robotName, robot_name: robotName, }; // Optimistically mark as active so the unmount cleanup will fire even if // the user navigates away before the backend reports calibration_active=true. // Reverted below if the start request fails. calibrationActiveRef.current = true; 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 { calibrationActiveRef.current = false; toast({ title: "Calibration Failed", description: result.message || "Failed to start calibration", variant: "destructive", }); } } catch (error) { calibrationActiveRef.current = false; console.error("Error starting calibration:", error); toast({ title: "Error", description: "Failed to start calibration", variant: "destructive", }); } }; const handleStopCalibration = async () => { try { const response = await fetchWithHeaders(`${baseUrl}/stop-calibration`, { method: "POST", }); const result = await response.json(); if (result.success) { // The 200ms polling interval will pick up the stopped state. toast({ title: "Calibration Stopped", description: "Calibration has been stopped", }); } 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", }); } }; 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", }); } }; useEffect(() => { if ( calibrationStatus.status === "error" && calibrationStatus.error?.startsWith(DISCONTINUITY_ERROR_PREFIX) ) { demoVideoRef.current?.scrollIntoView({ behavior: "smooth", block: "center", }); } }, [calibrationStatus.status, calibrationStatus.error]); useEffect(() => { if (!isPolling) return; // Single stable interval. Reads calibration_active from the ref each tick so // the interval doesn't tear down/recreate on every status change. pollStatus(); const interval = setInterval(() => { pollStatus(); }, 200); return () => clearInterval(interval); // pollStatus is stable enough — it only reads via fetchWithHeaders + setState. // eslint-disable-next-line react-hooks/exhaustive-deps }, [isPolling]); // Load default port when device type changes (skip when arriving from a tile — // the robot-record prefill above wins) useEffect(() => { const loadDefaultPort = async () => { if (!deviceType) return; if (robotName) 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") { const portToUse = data.saved_port || data.default_port; if (portToUse) { setPort(portToUse); } } } catch (error) { console.error("Error loading default port:", error); } }; loadDefaultPort(); }, [deviceType, robotName, baseUrl, fetchWithHeaders]); const handleDeviceTypeChange = (next: string) => { setDeviceType(next); if (!robot) return; setPort( next === "teleop" ? robot.leader_port || "" : robot.follower_port || "" ); }; // Refresh the robot record when a calibration completes so the checklist // flips to ✓ for the side that was just saved, and advance Device Type to // the next still-incomplete side (or stay on the current side if both done). useEffect(() => { if (calibrationStatus.status !== "completed") return; (async () => { const r = await fetchRobot(); if (!r) return; const nextDevice = !r.leader_config ? "teleop" : !r.follower_config ? "robot" : "teleop"; setDeviceType(nextDevice); setPort( nextDevice === "teleop" ? r.leader_port || "" : r.follower_port || "" ); })(); }, [calibrationStatus.status, fetchRobot]); const handlePortDetection = () => { const robotType = deviceType === "robot" ? "follower" : "leader"; setDetectionRobotType(robotType); setShowPortDetection(true); }; const handlePortDetected = (detectedPort: string) => { setPort(detectedPort); }; const getStatusDisplay = () => { switch (calibrationStatus.status) { case "idle": return { color: "bg-slate-500", icon: , text: "Idle", }; case "connecting": return { color: "bg-yellow-500", icon: , text: "Connecting", }; case "recording": return { color: "bg-purple-500", icon: , text: "Recording Ranges", }; case "completed": return { color: "bg-green-500", icon: , text: "Completed", }; case "error": return { color: "bg-red-500", icon: , text: "Error", }; case "stopping": return { color: "bg-orange-500", icon: , text: "Stopping", }; default: return { color: "bg-slate-500", icon: , text: "Unknown", }; } }; const statusDisplay = getStatusDisplay(); return (

{robotName ? `Calibrate "${robotName}"` : "Device Calibration"}

{!robotName && ( Open Calibration from a robot's gear icon on the Landing page. Each robot has its own calibration; running this page directly is not supported. )}
Configuration
setPort(e.target.value)} placeholder="/dev/tty.usbmodem..." className="bg-slate-700 border-slate-600 text-white rounded-md flex-1" />
{!calibrationStatus.calibration_active ? ( ) : ( )}
{robot && (
Robot calibration
{robot.leader_config ? ( ) : ( )} Leader (Teleoperator)
{robot.follower_config ? ( ) : ( )} Follower (Robot)
)}
Status
Status: {statusDisplay.icon} {statusDisplay.text}
{calibrationStatus.status === "recording" && calibrationStatus.recorded_ranges && (
Live Position Data
{Object.entries(calibrationStatus.recorded_ranges).map( ([motor, range]) => { const totalRange = range.max - range.min; const currentOffset = range.current - range.min; const progressPercent = totalRange > 0 ? (currentOffset / totalRange) * 100 : 50; const rangeComplete = isMotorRangeComplete( calibrationStatus.device_type, motor, totalRange ); return (
{motor} {rangeComplete && ( )}
{range.current}
{range.min} {range.max}
); } )}
)} {calibrationStatus.status === "connecting" && ( Connecting to the device. Please ensure it's connected. )} {calibrationStatus.status === "recording" && (() => { const ranges = calibrationStatus.recorded_ranges ?? {}; const motors = Object.entries(ranges); const allComplete = motors.length > 0 && motors.every(([motor, range]) => isMotorRangeComplete( calibrationStatus.device_type, motor, range.max - range.min ) ); return (
Important: Move EACH joint through its full range. A check appears next to each joint once its range is wide enough.
); })()} {calibrationStatus.status === "completed" && ( Calibration completed successfully! )} {calibrationStatus.status === "error" && calibrationStatus.error && (calibrationStatus.error.startsWith( DISCONTINUITY_ERROR_PREFIX ) ? (
Motor discontinuity detected
Make sure to start the calibration with the robot in a middle position — all joints in the middle of their ranges. See the calibration demo below for the correct starting pose.
) : ( Error: {calibrationStatus.error} ))}

Calibration Demo:

{robotName && ( Attached cameras
{camerasActive ? ( ) : (

Cameras are off

Turn cameras on to scan for connected devices and preview them. The browser may briefly open a camera to read device labels, and configured cameras stay active while previews are visible; your browser will ask for camera permission. Nothing is recorded.

{cameras.length > 0 && (

{cameras.length} camera {cameras.length === 1 ? "" : "s"} saved to this robot.

)}

You'll be asked to grant camera access.

)}
)}
); }; export default Calibration;