LeRobot.js / packages /node /src /teleoperate.ts
NERDDISCO
feat: added node support (#8)
bdc1ac8 unverified
/**
* Node.js teleoperation functionality using serialport API
* Provides both Python lerobot compatible CLI behavior and programmatic usage
* Uses proven teleoperator classes with web-compatible API
*/
import { NodeSerialPortWrapper } from "./utils/serial-port-wrapper.js";
import { createSO100Config } from "./robots/so100_config.js";
import { readAllMotorPositions } from "./utils/motor-communication.js";
import {
KeyboardTeleoperator,
DirectTeleoperator,
} from "./teleoperators/index.js";
import { readFile } from "fs/promises";
import { join } from "path";
import { homedir } from "os";
import { existsSync } from "fs";
import type {
TeleoperateConfig,
TeleoperationProcess,
MotorConfig,
TeleoperationState,
} from "./types/teleoperation.js";
import type { RobotConnection } from "./types/robot-connection.js";
import type { CalibrationResults } from "./types/calibration.js";
/**
* Get calibration file path (matches Python lerobot location)
*/
function getCalibrationFilePath(robotType: string, robotId: string): string {
const HF_HOME =
process.env.HF_HOME || join(homedir(), ".cache", "huggingface");
const calibrationDir = join(
HF_HOME,
"lerobot",
"calibration",
"robots",
robotType
);
return join(calibrationDir, `${robotId}.json`);
}
/**
* Load calibration data from file system
*/
async function loadCalibrationData(
robotType: string,
robotId: string
): Promise<CalibrationResults | null> {
const calibrationPath = getCalibrationFilePath(robotType, robotId);
if (!existsSync(calibrationPath)) {
return null;
}
try {
const calibrationJson = await readFile(calibrationPath, "utf-8");
return JSON.parse(calibrationJson) as CalibrationResults;
} catch (error) {
console.warn(
`Failed to load calibration data from ${calibrationPath}:`,
error
);
return null;
}
}
/**
* Build motor configurations from robot config and calibration data
*/
function buildMotorConfigs(
robotConfig: any,
calibrationData?: CalibrationResults | null
): MotorConfig[] {
const motorConfigs: MotorConfig[] = [];
for (let i = 0; i < robotConfig.motorNames.length; i++) {
const motorName = robotConfig.motorNames[i];
const motorId = robotConfig.motorIds[i];
let minPosition = 0;
let maxPosition = robotConfig.protocol.resolution - 1; // Default full range
// Use calibration data if available
if (calibrationData && calibrationData[motorName]) {
minPosition = calibrationData[motorName].range_min;
maxPosition = calibrationData[motorName].range_max;
}
motorConfigs.push({
id: motorId,
name: motorName,
currentPosition: Math.floor((minPosition + maxPosition) / 2), // Start at center
minPosition,
maxPosition,
});
}
return motorConfigs;
}
/**
* Main teleoperate function with web-compatible API
*/
export async function teleoperate(
config: TeleoperateConfig
): Promise<TeleoperationProcess> {
const { robot, teleop, calibrationData, onStateUpdate } = config;
// Validate robot configuration
if (!robot.robotType) {
throw new Error(
"Robot type is required for teleoperation. Please configure the robot first."
);
}
if (!robot.isConnected || !robot.port) {
throw new Error(
"Robot is not connected. Please use findPort() to connect first."
);
}
// Use the EXISTING port connection (don't create new one!)
const port = robot.port;
// Get robot-specific configuration
let robotConfig;
if (robot.robotType.startsWith("so100")) {
robotConfig = createSO100Config(robot.robotType);
} else {
throw new Error(`Unsupported robot type: ${robot.robotType}`);
}
// Load or use provided calibration data
let effectiveCalibrationData = calibrationData;
if (!effectiveCalibrationData && robot.robotId) {
effectiveCalibrationData = await loadCalibrationData(
robot.robotType,
robot.robotId
);
}
if (!effectiveCalibrationData) {
console.warn(
"No calibration data found. Using default motor ranges. Consider running calibration first."
);
}
// Build motor configurations
const motorConfigs = buildMotorConfigs(robotConfig, effectiveCalibrationData);
// Read current motor positions
try {
const currentPositions = await readAllMotorPositions(
port,
robotConfig.motorIds
);
for (let i = 0; i < motorConfigs.length; i++) {
motorConfigs[i].currentPosition = currentPositions[i];
}
} catch (error) {
console.warn("Failed to read initial motor positions:", error);
}
// Create appropriate teleoperator based on configuration
let teleoperator;
switch (teleop.type) {
case "keyboard":
teleoperator = new KeyboardTeleoperator(
teleop,
port,
motorConfigs,
robotConfig.keyboardControls,
onStateUpdate
);
break;
case "direct":
teleoperator = new DirectTeleoperator(teleop, port, motorConfigs);
break;
default:
throw new Error(`Unsupported teleoperator type: ${(teleop as any).type}`);
}
// Initialize teleoperator
await teleoperator.initialize();
// Build process object
const process: TeleoperationProcess = {
start(): void {
teleoperator.start();
},
stop(): void {
teleoperator.stop();
},
updateKeyState(key: string, pressed: boolean): void {
if ("updateKeyState" in teleoperator) {
(teleoperator as any).updateKeyState(key, pressed);
}
},
getState(): TeleoperationState {
const teleoperatorSpecificState = teleoperator.getState();
return {
isActive: teleoperator.isActiveTeleoperator,
motorConfigs: [...teleoperator.motorConfigs],
lastUpdate: Date.now(),
...teleoperatorSpecificState,
};
},
teleoperator: teleoperator,
async disconnect(): Promise<void> {
await teleoperator.disconnect();
},
};
return process;
}