/** * Browser implementation of find_port using WebSerial + WebUSB APIs * WebSerial: Communication with device * WebUSB: Device identification and serial numbers * * Usage Examples: * * // Interactive mode - always returns array * const findProcess = await findPort(); * const robotConnections = await findProcess.result; * const robot = robotConnections[0]; // First (and only) robot * await calibrate(robot, options); * * // Auto-connect mode - returns array of all attempted connections * const findProcess = await findPort({ * robotConfigs: [ * { robotType: "so100_follower", robotId: "arm1", serialNumber: "ABC123" }, * { robotType: "so100_leader", robotId: "arm2", serialNumber: "DEF456" } * ] * }); * const robotConnections = await findProcess.result; * for (const robot of robotConnections.filter(r => r.isConnected)) { * await calibrate(robot, options); * } * * // Store/load from localStorage * localStorage.setItem('myRobots', JSON.stringify(robotConnections)); * const storedRobots = JSON.parse(localStorage.getItem('myRobots')); * await calibrate(storedRobots[0], options); */ import { WebSerialPortWrapper } from "./utils/serial-port-wrapper.js"; import { readMotorPosition } from "./utils/motor-communication.js"; import { isWebSerialSupported, isWebUSBSupported, } from "./utils/browser-support.js"; import type { RobotConnection, RobotConfig, SerialPort, } from "./types/robot-connection.js"; import type { Serial, FindPortConfig, FindPortProcess, USBDevice, } from "./types/port-discovery.js"; /** * Get display name for a port */ function getPortDisplayName(port: SerialPort): string { const info = port.getInfo(); if (info.usbVendorId && info.usbProductId) { return `USB Device (${info.usbVendorId}:${info.usbProductId})`; } return "Serial Device"; } /** * Request USB device for metadata and serial number extraction */ async function requestUSBDeviceMetadata(): Promise<{ serialNumber: string; usbMetadata: RobotConnection["usbMetadata"]; }> { try { // Request USB device access for metadata (no filters - accept any device) const usbDevice = await navigator.usb.requestDevice({ filters: [] }); const serialNumber = usbDevice.serialNumber || `${usbDevice.vendorId}-${usbDevice.productId}-${Date.now()}`; const 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, }; return { serialNumber, usbMetadata }; } catch (usbError) { console.log("⚠️ WebUSB request failed, generating fallback ID:", usbError); // Generate a fallback unique ID if WebUSB fails const serialNumber = `fallback-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; const usbMetadata = { vendorId: "Unknown", productId: "Unknown", serialNumber: serialNumber, manufacturerName: "WebUSB Not Available", productName: "Check browser WebUSB support", }; return { serialNumber, usbMetadata }; } } /** * Get USB device metadata for already permitted devices */ async function getStoredUSBDeviceMetadata(port: SerialPort): Promise<{ serialNumber: string; usbMetadata?: RobotConnection["usbMetadata"]; }> { try { if (!isWebUSBSupported()) { throw new Error("WebUSB not supported"); } // Get already permitted USB devices const usbDevices = await navigator.usb.getDevices(); const portInfo = port.getInfo(); // Try to find matching USB device by vendor/product ID const matchingDevice = usbDevices.find( (device) => device.vendorId === portInfo.usbVendorId && device.productId === portInfo.usbProductId ); if (matchingDevice) { const serialNumber = matchingDevice.serialNumber || `${matchingDevice.vendorId}-${matchingDevice.productId}-${Date.now()}`; const usbMetadata = { vendorId: `0x${matchingDevice.vendorId.toString(16).padStart(4, "0")}`, productId: `0x${matchingDevice.productId .toString(16) .padStart(4, "0")}`, serialNumber: matchingDevice.serialNumber || "Generated ID", manufacturerName: matchingDevice.manufacturerName || "Unknown", productName: matchingDevice.productName || "Unknown", usbVersionMajor: matchingDevice.usbVersionMajor, usbVersionMinor: matchingDevice.usbVersionMinor, deviceClass: matchingDevice.deviceClass, deviceSubclass: matchingDevice.deviceSubclass, deviceProtocol: matchingDevice.deviceProtocol, }; console.log("✅ Restored USB metadata for port:", serialNumber); return { serialNumber, usbMetadata }; } throw new Error("No matching USB device found"); } catch (usbError) { console.log("⚠️ Could not restore USB metadata:", usbError); // Generate fallback if no USB metadata available const serialNumber = `fallback-${Date.now()}-${Math.random() .toString(36) .substr(2, 9)}`; return { serialNumber }; } } /** * Interactive mode: Show native dialogs for port + device selection */ async function findPortInteractive( options: FindPortConfig ): Promise { const { onMessage } = options; onMessage?.("Opening device selection dialogs..."); try { let serialNumber: string; let usbMetadata: RobotConnection["usbMetadata"]; onMessage?.("📱 Requesting device access permissions..."); // Step 1: Request serial port onMessage?.("📡 Step 1: Select serial port..."); const port = await navigator.serial.requestPort(); await port.open({ baudRate: 1000000 }); onMessage?.(`✅ Connected to ${getPortDisplayName(port)}`); // Step 2: Request USB device for identification if (isWebUSBSupported()) { onMessage?.("🆔 Step 2: Select device for identification..."); try { const usbData = await requestUSBDeviceMetadata(); serialNumber = usbData.serialNumber; usbMetadata = usbData.usbMetadata; onMessage?.(`🆔 Device ID: ${serialNumber}`); } catch (usbError) { onMessage?.("⚠️ Device identification failed, using fallback ID"); const fallbackId = `fallback-${Date.now()}`; serialNumber = fallbackId; usbMetadata = { vendorId: "Unknown", productId: "Unknown", serialNumber: fallbackId, manufacturerName: "USB Dialog Cancelled", productName: "User cancelled device selection", }; } } else { onMessage?.("⚠️ WebUSB not supported, using fallback ID"); const fallbackId = `no-usb-${Date.now()}`; serialNumber = fallbackId; usbMetadata = { vendorId: "Unknown", productId: "Unknown", serialNumber: fallbackId, manufacturerName: "WebUSB Not Supported", productName: "Browser limitation", }; } // Return unified RobotConnection object in array (consistent API) return [ { port, name: getPortDisplayName(port), isConnected: true, robotType: "so100_follower", // Default, user can change robotId: "interactive_robot", serialNumber, usbMetadata, }, ]; } catch (error) { if ( error instanceof Error && (error.message.includes("cancelled") || error.name === "NotAllowedError") ) { throw new Error("Port selection cancelled by user"); } throw new Error( `Failed to select port: ${error instanceof Error ? error.message : error}` ); } } /** * Auto-connect mode: Connect to robots by serial number * Returns all successfully connected robots */ async function findPortAutoConnect( robotConfigs: RobotConfig[], options: FindPortConfig ): Promise { const { onMessage } = options; const results: RobotConnection[] = []; onMessage?.(`🔍 Auto-connecting to ${robotConfigs.length} robot(s)...`); // Get all available ports const availablePorts = await navigator.serial.getPorts(); onMessage?.(`Found ${availablePorts.length} available port(s)`); // For each available port, try to restore USB metadata and match with configs for (const port of availablePorts) { try { // Get USB device metadata for this port const { serialNumber, usbMetadata } = await getStoredUSBDeviceMetadata( port ); // Find matching robot config by serial number const matchingConfig = robotConfigs.find( (config) => config.serialNumber === serialNumber ); if (matchingConfig) { onMessage?.( `Connecting to ${matchingConfig.robotId} (${serialNumber})...` ); try { // Try to open the port const wasOpen = port.readable !== null; if (!wasOpen) { await port.open({ baudRate: 1000000 }); // Small delay to allow port to stabilize after opening await new Promise((resolve) => setTimeout(resolve, 100)); } // Test connection by trying basic motor communication const portWrapper = new WebSerialPortWrapper(port); await portWrapper.initialize(); // Try to read from motor ID 1 (most robots have at least one motor) // Retry mechanism for more robust connection testing let testPosition: number | null = null; for (let attempt = 0; attempt < 3; attempt++) { try { testPosition = await readMotorPosition(portWrapper, 1); if (testPosition !== null) break; await new Promise((resolve) => setTimeout(resolve, 50)); } catch (retryError) { if (attempt === 2) throw retryError; await new Promise((resolve) => setTimeout(resolve, 100)); } } // If we can read a position, this is likely a working robot port if (testPosition !== null) { onMessage?.(`✅ Connected to ${matchingConfig.robotId}`); results.push({ port, name: getPortDisplayName(port), isConnected: true, robotType: matchingConfig.robotType, robotId: matchingConfig.robotId, serialNumber, usbMetadata, }); } else { throw new Error("No motor response - not a robot port"); } } catch (connectionError) { onMessage?.( `❌ Failed to connect to ${matchingConfig.robotId}: ${ connectionError instanceof Error ? connectionError.message : connectionError }` ); results.push({ port, name: getPortDisplayName(port), isConnected: false, robotType: matchingConfig.robotType, robotId: matchingConfig.robotId, serialNumber, usbMetadata, error: connectionError instanceof Error ? connectionError.message : "Unknown error", }); } } else { console.log( `Port with serial ${serialNumber} not in requested configs, skipping` ); } } catch (metadataError) { console.log(`Failed to get metadata for port:`, metadataError); // Skip this port if we can't get metadata continue; } } // Handle robots that weren't found for (const config of robotConfigs) { const found = results.some((r) => r.serialNumber === config.serialNumber); if (!found) { onMessage?.( `❌ Robot ${config.robotId} (${config.serialNumber}) not found` ); results.push({ port: null as any, // Will not be used since isConnected = false name: "Not Found", isConnected: false, robotType: config.robotType, robotId: config.robotId, serialNumber: config.serialNumber, error: `Device with serial number ${config.serialNumber} not found`, }); } } const successCount = results.filter((r) => r.isConnected).length; onMessage?.( `🎯 Connected to ${successCount}/${robotConfigs.length} robot(s)` ); return results; } /** * Main findPort function - clean API with Two modes: * * Mode 1: Interactive - Returns single RobotConnection * Mode 2: Auto-connect - Returns RobotConnection[] */ export async function findPort( config: FindPortConfig = {} ): Promise { // Check WebSerial support if (!isWebSerialSupported()) { throw new Error( "WebSerial API not supported. Please use Chrome/Edge 89+ with HTTPS or localhost." ); } const { robotConfigs, onMessage } = config; let stopped = false; // Determine mode const isAutoConnectMode = robotConfigs && robotConfigs.length > 0; onMessage?.( `🤖 ${ isAutoConnectMode ? "Auto-connect" : "Interactive" } port discovery started` ); // Create result promise const resultPromise = (async () => { if (stopped) { throw new Error("Port discovery was stopped"); } if (isAutoConnectMode) { return await findPortAutoConnect(robotConfigs!, config); } else { return await findPortInteractive(config); } })(); // Return process object return { result: resultPromise, stop: () => { stopped = true; onMessage?.("🛑 Port discovery stopped"); }, }; }