LeRobot.js / src /lerobot /web /find_port.ts
NERDDISCO's picture
feat: separated node & web
c8b4583
raw
history blame
8.05 kB
/**
* Browser implementation of find_port using WebSerial API
*
* Provides the same functionality as the Node.js version but adapted for browser environment
* Uses WebSerial API for serial port detection and user interaction through DOM
*/
/**
* Type definitions for WebSerial API (not yet in all TypeScript libs)
*/
interface SerialPort {
readonly readable: ReadableStream;
readonly writable: WritableStream;
getInfo(): SerialPortInfo;
open(options: SerialOptions): Promise<void>;
close(): Promise<void>;
}
interface SerialPortInfo {
usbVendorId?: number;
usbProductId?: number;
}
interface SerialOptions {
baudRate: number;
dataBits?: number;
stopBits?: number;
parity?: "none" | "even" | "odd";
}
interface Serial extends EventTarget {
getPorts(): Promise<SerialPort[]>;
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>;
}
interface SerialPortRequestOptions {
filters?: SerialPortFilter[];
}
interface SerialPortFilter {
usbVendorId?: number;
usbProductId?: number;
}
declare global {
interface Navigator {
serial: Serial;
}
}
/**
* Check if WebSerial API is available
*/
function isWebSerialSupported(): boolean {
return "serial" in navigator;
}
/**
* Get all available serial ports (requires user permission)
* Browser equivalent of Node.js findAvailablePorts()
*/
async function findAvailablePortsWeb(): Promise<SerialPort[]> {
if (!isWebSerialSupported()) {
throw new Error(
"WebSerial API not supported. Please use Chrome/Edge 89+ or Chrome Android 105+"
);
}
try {
return await navigator.serial.getPorts();
} catch (error) {
throw new Error(
`Failed to get serial ports: ${
error instanceof Error ? error.message : error
}`
);
}
}
/**
* Format port info for display
* Mimics the Node.js port listing format
*/
function formatPortInfo(ports: SerialPort[]): string[] {
return ports.map((port, index) => {
const info = port.getInfo();
if (info.usbVendorId && info.usbProductId) {
return `Port ${index + 1} (USB:${info.usbVendorId}:${info.usbProductId})`;
}
return `Port ${index + 1}`;
});
}
/**
* Sleep for specified milliseconds
* Same as Node.js version
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Wait for user interaction (button click or similar)
* Browser equivalent of Node.js readline input()
*/
function waitForUserAction(message: string): Promise<void> {
return new Promise((resolve) => {
const modal = document.createElement("div");
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
`;
const dialog = document.createElement("div");
dialog.style.cssText = `
background: white;
padding: 2rem;
border-radius: 8px;
text-align: center;
max-width: 500px;
margin: 1rem;
`;
dialog.innerHTML = `
<h3>Port Detection</h3>
<p style="margin: 1rem 0;">${message}</p>
<button id="continue-btn" style="
background: #3498db;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
">Continue</button>
`;
modal.appendChild(dialog);
document.body.appendChild(modal);
const continueBtn = dialog.querySelector(
"#continue-btn"
) as HTMLButtonElement;
continueBtn.addEventListener("click", () => {
document.body.removeChild(modal);
resolve();
});
});
}
/**
* Request permission to access serial ports
*/
async function requestSerialPermission(
logger: (message: string) => void
): Promise<void> {
logger("Requesting permission to access serial ports...");
logger(
'Please select a serial device when prompted, or click "Cancel" if no devices are connected yet.'
);
try {
// This will show the browser's serial port selection dialog
await navigator.serial.requestPort();
logger("✅ Permission granted to access serial ports.");
} catch (error) {
// User cancelled the dialog - this is OK, they might not have devices connected yet
console.log("Permission dialog cancelled:", error);
}
}
/**
* Main find port function for browser
* Maintains identical UX and messaging to Node.js version
*/
export async function findPortWeb(
logger: (message: string) => void
): Promise<void> {
logger("Finding all available ports for the MotorsBus.");
// Check WebSerial support
if (!isWebSerialSupported()) {
throw new Error(
"WebSerial API not supported. Please use Chrome/Edge 89+ with HTTPS or localhost."
);
}
// Get initial ports (check what we already have access to)
let portsBefore: SerialPort[];
try {
portsBefore = await findAvailablePortsWeb();
} catch (error) {
throw new Error(
`Failed to get serial ports: ${
error instanceof Error ? error.message : error
}`
);
}
// If no ports are available, request permission
if (portsBefore.length === 0) {
logger(
"⚠️ No serial ports available. Requesting permission to access devices..."
);
await requestSerialPermission(logger);
// Try again after permission request
portsBefore = await findAvailablePortsWeb();
if (portsBefore.length === 0) {
throw new Error(
'No ports detected. Please connect your devices, use "Show Available Ports" first, or check browser compatibility.'
);
}
}
// Show current ports
const portsBeforeFormatted = formatPortInfo(portsBefore);
logger(
`Ports before disconnecting: [${portsBeforeFormatted
.map((p) => `'${p}'`)
.join(", ")}]`
);
// Ask user to disconnect device
logger(
"Remove the USB cable from your MotorsBus and press Continue when done."
);
await waitForUserAction(
"Remove the USB cable from your MotorsBus and press Continue when done."
);
// Allow some time for port to be released (equivalent to Python's time.sleep(0.5))
await sleep(500);
// Get ports after disconnection
const portsAfter = await findAvailablePortsWeb();
const portsAfterFormatted = formatPortInfo(portsAfter);
logger(
`Ports after disconnecting: [${portsAfterFormatted
.map((p) => `'${p}'`)
.join(", ")}]`
);
// Find the difference by comparing port objects directly
// This handles cases where multiple devices have the same vendor/product ID
const removedPorts = portsBefore.filter((portBefore) => {
return !portsAfter.includes(portBefore);
});
// If object comparison fails (e.g., browser creates new objects), fall back to count-based detection
if (removedPorts.length === 0 && portsBefore.length > portsAfter.length) {
const countDifference = portsBefore.length - portsAfter.length;
if (countDifference === 1) {
logger(`The port of this MotorsBus is one of the disconnected devices.`);
logger(
"Note: Exact port identification not possible with identical devices."
);
logger("Reconnect the USB cable.");
return;
} else {
logger(`${countDifference} ports were removed, but expected exactly 1.`);
logger("Please disconnect only one device and try again.");
return;
}
}
if (removedPorts.length === 1) {
const port = formatPortInfo(removedPorts)[0];
logger(`The port of this MotorsBus is '${port}'`);
logger("Reconnect the USB cable.");
} else if (removedPorts.length === 0) {
logger("No difference found, did you remove the USB cable?");
logger("Please try again: disconnect one device and click Continue.");
return;
} else {
const portNames = formatPortInfo(removedPorts);
throw new Error(
`Could not detect the port. More than one port was found (${JSON.stringify(
portNames
)}).`
);
}
}