LeRobot.js / src /demo /components /PortManager.tsx
NERDDISCO's picture
feat: added react / tailwind ui
0d9f1af
raw
history blame
35.3 kB
import React, { useState, useEffect } from "react";
import { Button } from "./ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "./ui/card";
import { Alert, AlertDescription } from "./ui/alert";
import { Badge } from "./ui/badge";
import { isWebSerialSupported } from "../../lerobot/web/calibrate";
import type { ConnectedRobot } from "../types";
interface PortManagerProps {
connectedRobots: ConnectedRobot[];
onConnectedRobotsChange: (robots: ConnectedRobot[]) => void;
onCalibrate?: (
port: SerialPort,
robotType: "so100_follower" | "so100_leader",
robotId: string
) => void;
}
export function PortManager({
connectedRobots,
onConnectedRobotsChange,
onCalibrate,
}: PortManagerProps) {
const [isConnecting, setIsConnecting] = useState(false);
const [isFindingPorts, setIsFindingPorts] = useState(false);
const [findPortsLog, setFindPortsLog] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
// Load saved port data from localStorage on mount
useEffect(() => {
loadSavedPorts();
}, []);
// Save port data to localStorage whenever connectedPorts changes
useEffect(() => {
savePortsToStorage();
}, [connectedRobots]);
const loadSavedPorts = async () => {
try {
const saved = localStorage.getItem("lerobot-ports");
if (!saved) return;
const savedData = JSON.parse(saved);
const existingPorts = await navigator.serial.getPorts();
const restoredPorts: ConnectedRobot[] = [];
for (const port of existingPorts) {
// Find saved data by matching port info instead of display name
const portInfo = port.getInfo();
const savedPort = savedData.find((p: any) => {
// Try to match by USB vendor/product ID if available
if (portInfo.usbVendorId && portInfo.usbProductId) {
return (
p.usbVendorId === portInfo.usbVendorId &&
p.usbProductId === portInfo.usbProductId
);
}
// Fallback to name matching
return p.name === getPortDisplayName(port);
});
// Auto-connect to paired robots
let isConnected = false;
try {
// Check if already open
if (port.readable !== null && port.writable !== null) {
isConnected = true;
} else {
// Auto-open paired robots
await port.open({ baudRate: 1000000 });
isConnected = true;
}
} catch (error) {
console.log("Could not auto-connect to paired robot:", error);
isConnected = false;
}
restoredPorts.push({
port,
name: getPortDisplayName(port),
isConnected,
robotType: savedPort?.robotType,
robotId: savedPort?.robotId,
});
}
onConnectedRobotsChange(restoredPorts);
} catch (error) {
console.error("Failed to load saved ports:", error);
}
};
const savePortsToStorage = () => {
try {
const dataToSave = connectedRobots.map((p) => {
const portInfo = p.port.getInfo();
return {
name: p.name,
robotType: p.robotType,
robotId: p.robotId,
usbVendorId: portInfo.usbVendorId,
usbProductId: portInfo.usbProductId,
};
});
localStorage.setItem("lerobot-ports", JSON.stringify(dataToSave));
} catch (error) {
console.error("Failed to save ports to storage:", error);
}
};
const getPortDisplayName = (port: SerialPort): string => {
try {
const info = port.getInfo();
if (info.usbVendorId && info.usbProductId) {
return `USB Port (${info.usbVendorId}:${info.usbProductId})`;
}
if (info.usbVendorId) {
return `Serial Port (VID:${info.usbVendorId
.toString(16)
.toUpperCase()})`;
}
} catch (error) {
// getInfo() might not be available
}
return `Serial Port ${Date.now()}`;
};
const handleConnect = async () => {
if (!isWebSerialSupported()) {
setError("Web Serial API is not supported in this browser");
return;
}
try {
setIsConnecting(true);
setError(null);
// Step 1: Request Web Serial port
console.log("Step 1: Requesting Web Serial port...");
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 1000000 });
// Step 2: Request WebUSB device for metadata
console.log(
"Step 2: Requesting WebUSB device for unique identification..."
);
let serialNumber = null;
let usbMetadata = null;
try {
// Request USB device access for metadata
const usbDevice = await navigator.usb.requestDevice({
filters: [
{ vendorId: 0x0403 }, // FTDI
{ vendorId: 0x067b }, // Prolific
{ vendorId: 0x10c4 }, // Silicon Labs
{ vendorId: 0x1a86 }, // QinHeng Electronics (CH340)
{ vendorId: 0x239a }, // Adafruit
{ vendorId: 0x2341 }, // Arduino
{ vendorId: 0x2e8a }, // Raspberry Pi Foundation
{ vendorId: 0x1b4f }, // SparkFun
],
});
if (usbDevice) {
serialNumber =
usbDevice.serialNumber ||
`${usbDevice.vendorId}-${usbDevice.productId}-${Date.now()}`;
usbMetadata = {
vendorId: `0x${usbDevice.vendorId.toString(16).padStart(4, "0")}`,
productId: `0x${usbDevice.productId.toString(16).padStart(4, "0")}`,
serialNumber: usbDevice.serialNumber || "Generated ID",
manufacturerName: usbDevice.manufacturerName || "Unknown",
productName: usbDevice.productName || "Unknown",
usbVersionMajor: usbDevice.usbVersionMajor,
usbVersionMinor: usbDevice.usbVersionMinor,
deviceClass: usbDevice.deviceClass,
deviceSubclass: usbDevice.deviceSubclass,
deviceProtocol: usbDevice.deviceProtocol,
};
console.log("✅ USB device metadata acquired:", usbMetadata);
}
} catch (usbError) {
console.log(
"⚠️ WebUSB request failed, generating fallback ID:",
usbError
);
// Generate a fallback unique ID if WebUSB fails
serialNumber = `fallback-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
usbMetadata = {
vendorId: "Unknown",
productId: "Unknown",
serialNumber: serialNumber,
manufacturerName: "USB Metadata Not Available",
productName: "Check browser WebUSB support",
};
}
const portName = getPortDisplayName(port);
// Step 3: Check if this robot (by serial number) is already connected
const existingIndex = connectedRobots.findIndex(
(robot) => robot.serialNumber === serialNumber
);
if (existingIndex === -1) {
// New robot - add to list
const newRobot: ConnectedRobot = {
port,
name: portName,
isConnected: true,
serialNumber: serialNumber!,
usbMetadata: usbMetadata || undefined,
};
// Try to load saved robot info by serial number
try {
const savedRobotKey = `lerobot-robot-${serialNumber}`;
const savedData = localStorage.getItem(savedRobotKey);
if (savedData) {
const parsed = JSON.parse(savedData);
newRobot.robotType = parsed.robotType;
newRobot.robotId = parsed.robotId;
console.log("📋 Loaded saved robot configuration:", parsed);
}
} catch (error) {
console.warn("Failed to load saved robot data:", error);
}
onConnectedRobotsChange([...connectedRobots, newRobot]);
console.log("🤖 New robot connected with ID:", serialNumber);
} else {
// Existing robot - update port and connection status
const updatedRobots = connectedRobots.map((robot, index) =>
index === existingIndex
? { ...robot, port, isConnected: true, name: portName }
: robot
);
onConnectedRobotsChange(updatedRobots);
console.log("🔄 Existing robot reconnected:", serialNumber);
}
} catch (error) {
if (
error instanceof Error &&
(error.message.includes("cancelled") ||
error.message.includes("No port selected by the user") ||
error.name === "NotAllowedError")
) {
// User cancelled - no error message needed, just log to console
console.log("Connection cancelled by user");
return;
}
setError(
error instanceof Error ? error.message : "Failed to connect to robot"
);
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = async (index: number) => {
try {
const portInfo = connectedRobots[index];
if (portInfo.isConnected) {
await portInfo.port.close();
}
const updatedRobots = connectedRobots.filter((_, i) => i !== index);
onConnectedRobotsChange(updatedRobots);
} catch (error) {
setError(
error instanceof Error ? error.message : "Failed to disconnect port"
);
}
};
const handleUpdatePortInfo = (
index: number,
robotType: "so100_follower" | "so100_leader",
robotId: string
) => {
const updatedRobots = connectedRobots.map((robot, i) => {
if (i === index) {
const updatedRobot = { ...robot, robotType, robotId };
// Save robot configuration to localStorage using serial number
if (updatedRobot.serialNumber) {
try {
const robotKey = `lerobot-robot-${updatedRobot.serialNumber}`;
const robotData = {
robotType,
robotId,
serialNumber: updatedRobot.serialNumber,
lastUpdated: new Date().toISOString(),
};
localStorage.setItem(robotKey, JSON.stringify(robotData));
console.log(
"💾 Saved robot configuration for:",
updatedRobot.serialNumber
);
} catch (error) {
console.warn("Failed to save robot configuration:", error);
}
}
return updatedRobot;
}
return robot;
});
onConnectedRobotsChange(updatedRobots);
};
const handleFindPorts = async () => {
if (!isWebSerialSupported()) {
setError("Web Serial API is not supported in this browser");
return;
}
try {
setIsFindingPorts(true);
setFindPortsLog([]);
setError(null);
// Get initial ports
const initialPorts = await navigator.serial.getPorts();
setFindPortsLog((prev) => [
...prev,
`Found ${initialPorts.length} existing paired port(s)`,
]);
// Ask user to disconnect
setFindPortsLog((prev) => [
...prev,
"Please disconnect the USB cable from your robot and click OK",
]);
// Simple implementation - just show the instruction
// In a real implementation, we'd monitor port changes
const confirmed = confirm(
"Disconnect the USB cable from your robot and click OK when done"
);
if (confirmed) {
setFindPortsLog((prev) => [...prev, "Reconnect the USB cable now"]);
// Request port selection
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 1000000 });
const portName = getPortDisplayName(port);
setFindPortsLog((prev) => [...prev, `Identified port: ${portName}`]);
// Add to connected ports if not already there
const existingIndex = connectedRobots.findIndex(
(p) => p.name === portName
);
if (existingIndex === -1) {
const newPort: ConnectedRobot = {
port,
name: portName,
isConnected: true,
};
onConnectedRobotsChange([...connectedRobots, newPort]);
}
}
} catch (error) {
if (
error instanceof Error &&
(error.message.includes("cancelled") ||
error.name === "NotAllowedError")
) {
// User cancelled - no message needed, just log to console
console.log("Port identification cancelled by user");
return;
}
setError(error instanceof Error ? error.message : "Failed to find ports");
} finally {
setIsFindingPorts(false);
}
};
const ensurePortIsOpen = async (robotIndex: number) => {
const robot = connectedRobots[robotIndex];
if (!robot) return false;
try {
// If port is already open, we're good
if (robot.port.readable !== null && robot.port.writable !== null) {
return true;
}
// Try to open the port
await robot.port.open({ baudRate: 1000000 });
// Update the robot's connection status
const updatedRobots = connectedRobots.map((r, i) =>
i === robotIndex ? { ...r, isConnected: true } : r
);
onConnectedRobotsChange(updatedRobots);
return true;
} catch (error) {
console.error("Failed to open port for calibration:", error);
setError(error instanceof Error ? error.message : "Failed to open port");
return false;
}
};
const handleCalibrate = async (port: ConnectedRobot) => {
if (!port.robotType || !port.robotId) {
setError("Please set robot type and ID before calibrating");
return;
}
// Find the robot index
const robotIndex = connectedRobots.findIndex((r) => r.port === port.port);
if (robotIndex === -1) {
setError("Robot not found in connected robots list");
return;
}
// Ensure port is open before calibrating
const isOpen = await ensurePortIsOpen(robotIndex);
if (!isOpen) {
return; // Error already set in ensurePortIsOpen
}
if (onCalibrate) {
onCalibrate(port.port, port.robotType, port.robotId);
}
};
return (
<Card>
<CardHeader>
<CardTitle>🔌 Robot Connection Manager</CardTitle>
<CardDescription>
Connect, identify, and manage your robot arms
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Error Display */}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Connection Controls */}
<div className="flex gap-2">
<Button
onClick={handleConnect}
disabled={isConnecting || !isWebSerialSupported()}
className="flex-1"
>
{isConnecting ? "Connecting..." : "Connect Robot"}
</Button>
<Button
variant="outline"
onClick={handleFindPorts}
disabled={isFindingPorts || !isWebSerialSupported()}
className="flex-1"
>
{isFindingPorts ? "Finding..." : "Find Port"}
</Button>
</div>
{/* Find Ports Log */}
{findPortsLog.length > 0 && (
<div className="bg-gray-50 p-3 rounded-md text-sm space-y-1">
{findPortsLog.map((log, index) => (
<div key={index} className="text-gray-700">
{log}
</div>
))}
</div>
)}
{/* Connected Ports */}
<div>
<h4 className="font-semibold mb-3">
Connected Robots ({connectedRobots.length})
</h4>
{connectedRobots.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="text-2xl mb-2">🤖</div>
<p>No robots connected</p>
<p className="text-xs">
Use "Connect Robot" or "Find Port" to add robots
</p>
</div>
) : (
<div className="space-y-4">
{connectedRobots.map((portInfo, index) => (
<PortCard
key={index}
portInfo={portInfo}
onDisconnect={() => handleDisconnect(index)}
onUpdateInfo={(robotType, robotId) =>
handleUpdatePortInfo(index, robotType, robotId)
}
onCalibrate={() => handleCalibrate(portInfo)}
/>
))}
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}
interface PortCardProps {
portInfo: ConnectedRobot;
onDisconnect: () => void;
onUpdateInfo: (
robotType: "so100_follower" | "so100_leader",
robotId: string
) => void;
onCalibrate: () => void;
}
function PortCard({
portInfo,
onDisconnect,
onUpdateInfo,
onCalibrate,
}: PortCardProps) {
const [robotType, setRobotType] = useState<"so100_follower" | "so100_leader">(
portInfo.robotType || "so100_follower"
);
const [robotId, setRobotId] = useState(portInfo.robotId || "");
const [isEditing, setIsEditing] = useState(false);
const [isScanning, setIsScanning] = useState(false);
const [motorIDs, setMotorIDs] = useState<number[]>([]);
const [portMetadata, setPortMetadata] = useState<any>(null);
const [showDeviceInfo, setShowDeviceInfo] = useState(false);
// Check for calibration in localStorage using serial number
const getCalibrationStatus = () => {
if (!portInfo.serialNumber) return null;
const calibrationKey = `lerobot-calibration-${portInfo.serialNumber}`;
try {
const saved = localStorage.getItem(calibrationKey);
if (saved) {
const calibrationData = JSON.parse(saved);
return {
timestamp: calibrationData.timestamp,
readCount: calibrationData.readCount,
};
}
} catch (error) {
console.warn("Failed to read calibration from localStorage:", error);
}
return null;
};
const calibrationStatus = getCalibrationStatus();
const handleSave = () => {
if (robotId.trim()) {
onUpdateInfo(robotType, robotId.trim());
setIsEditing(false);
}
};
// Use current values (either from props or local state)
const currentRobotType = portInfo.robotType || robotType;
const currentRobotId = portInfo.robotId || robotId;
const handleCancel = () => {
setRobotType(portInfo.robotType || "so100_follower");
setRobotId(portInfo.robotId || "");
setIsEditing(false);
};
// Scan for motor IDs and gather USB device metadata
const scanDeviceInfo = async () => {
if (!portInfo.port || !portInfo.isConnected) {
console.warn("Port not connected");
return;
}
setIsScanning(true);
setMotorIDs([]);
setPortMetadata(null);
const foundIDs: number[] = [];
try {
// Try to get USB device info using WebUSB for better metadata
let usbDeviceInfo = null;
try {
// First, check if we already have USB device permissions
let usbDevices = await navigator.usb.getDevices();
console.log("Already permitted USB devices:", usbDevices);
// If no devices found, request permission for USB-to-serial devices
if (usbDevices.length === 0) {
console.log(
"No USB permissions yet, requesting access to USB-to-serial devices..."
);
// Request access to common USB-to-serial chips
try {
const device = await navigator.usb.requestDevice({
filters: [
{ vendorId: 0x0403 }, // FTDI
{ vendorId: 0x067b }, // Prolific
{ vendorId: 0x10c4 }, // Silicon Labs
{ vendorId: 0x1a86 }, // QinHeng Electronics (CH340)
{ vendorId: 0x239a }, // Adafruit
{ vendorId: 0x2341 }, // Arduino
{ vendorId: 0x2e8a }, // Raspberry Pi Foundation
{ vendorId: 0x1b4f }, // SparkFun
],
});
if (device) {
usbDevices = [device];
console.log("USB device access granted:", device);
}
} catch (requestError) {
console.log(
"User cancelled USB device selection or no devices found"
);
// Try requesting any device as fallback
try {
const anyDevice = await navigator.usb.requestDevice({
filters: [], // Allow any USB device
});
if (anyDevice) {
usbDevices = [anyDevice];
console.log("Fallback USB device selected:", anyDevice);
}
} catch (fallbackError) {
console.log("No USB device selected");
}
}
}
// Try to match with Web Serial port (this is tricky, so we'll take the first available)
if (usbDevices.length > 0) {
// Look for common USB-to-serial chip vendor IDs
const serialChipVendors = [
0x0403, // FTDI
0x067b, // Prolific
0x10c4, // Silicon Labs
0x1a86, // QinHeng Electronics (CH340)
0x239a, // Adafruit
0x2341, // Arduino
0x2e8a, // Raspberry Pi Foundation
0x1b4f, // SparkFun
];
const serialDevice =
usbDevices.find((device) =>
serialChipVendors.includes(device.vendorId)
) || usbDevices[0]; // Fallback to first device
if (serialDevice) {
usbDeviceInfo = {
vendorId: `0x${serialDevice.vendorId
.toString(16)
.padStart(4, "0")}`,
productId: `0x${serialDevice.productId
.toString(16)
.padStart(4, "0")}`,
serialNumber: serialDevice.serialNumber || "Not available",
manufacturerName: serialDevice.manufacturerName || "Unknown",
productName: serialDevice.productName || "Unknown",
usbVersionMajor: serialDevice.usbVersionMajor,
usbVersionMinor: serialDevice.usbVersionMinor,
deviceClass: serialDevice.deviceClass,
deviceSubclass: serialDevice.deviceSubclass,
deviceProtocol: serialDevice.deviceProtocol,
};
console.log("USB device info:", usbDeviceInfo);
}
}
} catch (usbError) {
console.log("WebUSB not available or no permissions:", usbError);
// Fallback to Web Serial API info
const portInfo_metadata = portInfo.port.getInfo();
console.log("Serial port metadata fallback:", portInfo_metadata);
if (Object.keys(portInfo_metadata).length > 0) {
usbDeviceInfo = {
vendorId: portInfo_metadata.usbVendorId
? `0x${portInfo_metadata.usbVendorId
.toString(16)
.padStart(4, "0")}`
: "Not available",
productId: portInfo_metadata.usbProductId
? `0x${portInfo_metadata.usbProductId
.toString(16)
.padStart(4, "0")}`
: "Not available",
serialNumber: "Not available via Web Serial",
manufacturerName: "Not available via Web Serial",
productName: "Not available via Web Serial",
};
}
}
setPortMetadata(usbDeviceInfo);
// Get reader/writer for the port
const reader = portInfo.port.readable?.getReader();
const writer = portInfo.port.writable?.getWriter();
if (!reader || !writer) {
console.warn("Cannot access port reader/writer");
setShowDeviceInfo(true);
return;
}
// Test motor IDs 1-10 (common range for servos)
for (let motorId = 1; motorId <= 10; motorId++) {
try {
// Create STS3215 ping packet
const packet = new Uint8Array([
0xff,
0xff,
motorId,
0x02,
0x01,
0x00,
]);
const checksum = ~(motorId + 0x02 + 0x01) & 0xff;
packet[5] = checksum;
// Send ping
await writer.write(packet);
// Wait a bit for response
await new Promise((resolve) => setTimeout(resolve, 20));
// Try to read response with timeout
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 50)
);
try {
const result = (await Promise.race([
reader.read(),
timeoutPromise,
])) as ReadableStreamReadResult<Uint8Array>;
if (
result &&
!result.done &&
result.value &&
result.value.length >= 6
) {
const response = result.value;
const responseId = response[2];
// If we got a response with matching ID, motor exists
if (responseId === motorId) {
foundIDs.push(motorId);
}
}
} catch (readError) {
// No response from this motor ID - that's normal
}
} catch (error) {
console.warn(`Error testing motor ID ${motorId}:`, error);
}
// Small delay between tests
await new Promise((resolve) => setTimeout(resolve, 10));
}
reader.releaseLock();
writer.releaseLock();
setMotorIDs(foundIDs);
setShowDeviceInfo(true);
} catch (error) {
console.error("Device info scan failed:", error);
} finally {
setIsScanning(false);
}
};
return (
<div className="border rounded-lg p-4 space-y-3">
{/* Header with port name and status */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="flex flex-col">
<span className="font-medium">{portInfo.name}</span>
{portInfo.serialNumber && (
<span className="text-xs text-gray-500 font-mono">
ID:{" "}
{portInfo.serialNumber.length > 20
? portInfo.serialNumber.substring(0, 20) + "..."
: portInfo.serialNumber}
</span>
)}
</div>
<Badge variant={portInfo.isConnected ? "default" : "outline"}>
{portInfo.isConnected ? "Connected" : "Available"}
</Badge>
{portInfo.usbMetadata && (
<Badge variant="outline" className="text-xs">
{portInfo.usbMetadata.manufacturerName}
</Badge>
)}
</div>
<Button variant="destructive" size="sm" onClick={onDisconnect}>
Remove
</Button>
</div>
{/* Robot Info Display (when not editing) */}
{!isEditing && currentRobotType && currentRobotId && (
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
<div>
<div className="font-medium text-sm">{currentRobotId}</div>
<div className="text-xs text-gray-600">
{currentRobotType.replace("_", " ")}
</div>
</div>
{calibrationStatus && (
<Badge variant="default" className="bg-green-100 text-green-800">
✅ Calibrated
</Badge>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
>
Edit
</Button>
</div>
)}
{/* Setup prompt for unconfigured robots */}
{!isEditing && (!currentRobotType || !currentRobotId) && (
<div className="flex items-center justify-between p-3 bg-blue-50 rounded-lg">
<div className="text-sm text-blue-800">
Robot needs configuration before use
</div>
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
>
Configure
</Button>
</div>
)}
{/* Robot Configuration Form (when editing) */}
{isEditing && (
<div className="space-y-3 p-3 bg-gray-50 rounded-lg">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium block mb-1">
Robot Type
</label>
<select
value={robotType}
onChange={(e) =>
setRobotType(
e.target.value as "so100_follower" | "so100_leader"
)
}
className="w-full px-2 py-1 border rounded text-sm"
>
<option value="so100_follower">SO-100 Follower</option>
<option value="so100_leader">SO-100 Leader</option>
</select>
</div>
<div>
<label className="text-sm font-medium block mb-1">Robot ID</label>
<input
type="text"
value={robotId}
onChange={(e) => setRobotId(e.target.value)}
placeholder="e.g., my_robot"
className="w-full px-2 py-1 border rounded text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={handleSave} disabled={!robotId.trim()}>
Save
</Button>
<Button size="sm" variant="outline" onClick={handleCancel}>
Cancel
</Button>
</div>
</div>
)}
{/* Calibration Status and Action */}
{currentRobotType && currentRobotId && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
{calibrationStatus ? (
<span>
Last calibrated:{" "}
{new Date(calibrationStatus.timestamp).toLocaleDateString()}
<span className="text-xs ml-1">
({calibrationStatus.readCount} readings)
</span>
</span>
) : (
<span>Not calibrated yet</span>
)}
</div>
<Button
size="sm"
variant={calibrationStatus ? "outline" : "default"}
onClick={onCalibrate}
disabled={!currentRobotType || !currentRobotId}
>
{calibrationStatus ? "Re-calibrate" : "Calibrate"}
</Button>
</div>
{/* Device Info Scanner */}
<div className="flex items-center justify-between">
<div className="text-sm text-gray-600">
Scan device info and motor IDs
</div>
<Button
size="sm"
variant="outline"
onClick={scanDeviceInfo}
disabled={!portInfo.isConnected || isScanning}
>
{isScanning ? "Scanning..." : "Show Device Info"}
</Button>
</div>
{/* Device Info Results */}
{showDeviceInfo && (
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
{/* USB Device Information */}
{portMetadata && (
<div>
<div className="text-sm font-medium mb-2">
📱 USB Device Info:
</div>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-600">Vendor ID:</span>
<span className="font-mono">{portMetadata.vendorId}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Product ID:</span>
<span className="font-mono">
{portMetadata.productId}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Serial Number:</span>
<span className="font-mono text-green-600 font-semibold">
{portMetadata.serialNumber}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Manufacturer:</span>
<span>{portMetadata.manufacturerName}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Product:</span>
<span>{portMetadata.productName}</span>
</div>
{portMetadata.usbVersionMajor && (
<div className="flex justify-between">
<span className="text-gray-600">USB Version:</span>
<span>
{portMetadata.usbVersionMajor}.
{portMetadata.usbVersionMinor}
</span>
</div>
)}
{portMetadata.deviceClass !== undefined && (
<div className="flex justify-between">
<span className="text-gray-600">Device Class:</span>
<span>
0x
{portMetadata.deviceClass
.toString(16)
.padStart(2, "0")}
</span>
</div>
)}
</div>
</div>
)}
{/* Motor IDs */}
<div>
<div className="text-sm font-medium mb-2">
🤖 Found Motor IDs:
</div>
{motorIDs.length > 0 ? (
<div className="flex flex-wrap gap-2">
{motorIDs.map((id) => (
<Badge key={id} variant="outline" className="text-xs">
Motor {id}
</Badge>
))}
</div>
) : (
<div className="text-sm text-gray-500">
No motor IDs found. Check connection and power.
</div>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => setShowDeviceInfo(false)}
className="mt-2 text-xs"
>
Hide
</Button>
</div>
)}
</div>
)}
</div>
);
}