LeRobot.js / packages /web /src /teleoperators /base-teleoperator.ts
Aditya Shankar
feat: added dataset recording; hf uploader, s3 uploader; runpod trainer (#6)
4384839 unverified
/**
* Base teleoperator interface and abstract class for Web platform
* Defines the contract that all teleoperators must implement
*/
import type { MotorConfig } from "../types/teleoperation.js";
import type { MotorCommunicationPort } from "../utils/motor-communication.js";
/**
* Normalizes a value from one range to another
* @param value The value to normalize
* @param minVal The minimum value of the original range
* @param maxVal The maximum value of the original range
* @param minNorm The minimum value of the normalized range
* @param maxNorm The maximum value of the normalized range
* @returns The normalized value
*/
function normalizeValue(value: number, minVal: number, maxVal: number, minNorm: number, maxNorm: number): number {
const range = maxVal - minVal;
const normRange = maxNorm - minNorm;
const normalized = (value - minVal) / range;
return normalized * normRange + minNorm;
}
/**
* Type definition for state update callback parameters
*/
interface StateUpdateCallbackParams {
previousMotorConfigs: MotorConfig[];
newMotorConfigs: MotorConfig[];
previousMotorConfigsNormalized: MotorConfig[];
newMotorConfigsNormalized: MotorConfig[];
commandSentTimestamp: number;
positionChangedTimestamp: number;
}
/**
* Type definition for state update callback function
*/
type StateUpdateCallback = (params: StateUpdateCallbackParams) => void;
/**
* Type definition for array of state update callbacks
*/
type StateUpdateCallbackArray = Array<StateUpdateCallback>;
/**
* Base interface that all Web teleoperators must implement
*/
export abstract class WebTeleoperator {
protected onStateUpdateCallbacks: StateUpdateCallbackArray = [];
public motorConfigs: MotorConfig[] = [];
abstract initialize(): Promise<void>;
abstract start(): void;
abstract stop(): void;
abstract recordingTaskIndex : number;
abstract recordingEpisodeIndex : number;
abstract setEpisodeIndex(index: number): void;
abstract setTaskIndex(index: number): void;
abstract disconnect(): Promise<void>;
abstract getState(): TeleoperatorSpecificState;
abstract onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void;
abstract addOnStateUpdateCallback(fn : StateUpdateCallback): void;
}
/**
* Teleoperator-specific state (union type for different teleoperator types)
*/
export type TeleoperatorSpecificState = {
keyStates?: { [key: string]: { pressed: boolean; timestamp: number } }; // keyboard
leaderPositions?: { [motor: string]: number }; // leader arm
gamepadState?: { axes: number[]; buttons: boolean[] }; // gamepad
};
/**
* Base abstract class with common functionality for all teleoperators
*/
export abstract class BaseWebTeleoperator extends WebTeleoperator {
protected port: MotorCommunicationPort;
public motorConfigs: MotorConfig[] = [];
protected isActive: boolean = false;
public isRecording: boolean = false;
public recordingTaskIndex : number;
public recordingEpisodeIndex : number;
public recordedMotorPositionEpisodes : any[];
constructor(port: MotorCommunicationPort, motorConfigs: MotorConfig[]) {
super();
this.port = port;
this.motorConfigs = motorConfigs;
// store episode positions
this.recordedMotorPositionEpisodes = []
this.isRecording = false;
this.recordingTaskIndex = 0;
this.recordingEpisodeIndex = 0;
}
abstract initialize(): Promise<void>;
abstract start(): void;
abstract stop(): void;
abstract getState(): TeleoperatorSpecificState;
async disconnect(): Promise<void> {
this.stop();
if (this.port && "close" in this.port) {
await (this.port as any).close();
}
}
setEpisodeIndex(index: number): void {
this.recordingEpisodeIndex = index;
// create a new empty array at that position on the array
this.recordedMotorPositionEpisodes[this.recordingEpisodeIndex] = []
}
setTaskIndex(index: number): void {
this.recordingTaskIndex = index;
}
onMotorConfigsUpdate(motorConfigs: MotorConfig[]): void {
this.motorConfigs = motorConfigs;
}
normalizeMotorConfigPosition(motorConfig: MotorConfig){
let minNormPosition;
let maxNormPosition;
/**
* This follows the guide at https://github.com/huggingface/lerobot/blob/cf86b9300dc83fdad408cfe4787b7b09b55f12cf/src/lerobot/robots/so100_follower/so100_follower.py#L49
* Meaning, for everything except gripper, it normalizes the positions to between -100 and 100
* and for gripper it normalizes between 0 - 100
*/
if(["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll"].includes(motorConfig.name)) {
minNormPosition = -100;
maxNormPosition = 100;
} else {
minNormPosition = 0;
maxNormPosition = 100;
}
return normalizeValue(motorConfig.currentPosition, motorConfig.minPosition, motorConfig.maxPosition, minNormPosition, maxNormPosition)
}
/**
* Normalize an entire list of motor configurations
*
* This follows the guide at https://github.com/huggingface/lerobot/blob/cf86b9300dc83fdad408cfe4787b7b09b55f12cf/src/lerobot/robots/so100_follower/so100_follower.py#L49
* Meaning, for everything except gripper, it normalizes the positions to between -100 and 100
* and for gripper it normalizes between 0 - 100
*/
normalizeMotorConfigs(motorConfigs : MotorConfig[]) : MotorConfig[] {
// Create a deep copy of the motor configs
const normalizedConfigs = JSON.parse(JSON.stringify(motorConfigs)) as MotorConfig[];
// Normalize the current position values
for(let i = 0; i < normalizedConfigs.length; i++) {
const config = normalizedConfigs[i];
if(config.name === "gripper") {
config.currentPosition = normalizeValue(motorConfigs[i].currentPosition, motorConfigs[i].minPosition, motorConfigs[i].maxPosition, 0, 100);
} else {
config.currentPosition = normalizeValue(motorConfigs[i].currentPosition, motorConfigs[i].minPosition, motorConfigs[i].maxPosition, -100, 100);
}
// Also normalize min/max positions for consistency
if(config.name === "gripper") {
config.minPosition = 0;
config.maxPosition = 100;
} else {
config.minPosition = -100;
config.maxPosition = 100;
}
}
return normalizedConfigs;
}
/**
* Dispatches a motor position changed event
* Gets the motor positions, normalized
*
* This follows the guide at https://github.com/huggingface/lerobot/blob/cf86b9300dc83fdad408cfe4787b7b09b55f12cf/src/lerobot/robots/so100_follower/so100_follower.py#L49
* Meaning, for everything except gripper, it normalizes the positions to between -100 and 100
* and for gripper it normalizes between 0 - 100
*
* @param motorName The name of the motor that changed
* @param motorConfig The motor configuration
* @param previousPosition The previous position of the motor
* @param currentPosition The current position of the motor
* @param timestamp The timestamp of the event
*/
dispatchMotorPositionChanged(prevMotorConfigs: MotorConfig[], newMotorConfigs: MotorConfig[], commandSentTimestamp: number, positionChangedTimestamp: number): void {
// Call all registered state update callbacks
const callbackParams: StateUpdateCallbackParams = {
previousMotorConfigs: prevMotorConfigs,
newMotorConfigs: newMotorConfigs,
previousMotorConfigsNormalized: this.normalizeMotorConfigs(prevMotorConfigs),
newMotorConfigsNormalized: this.normalizeMotorConfigs(newMotorConfigs),
commandSentTimestamp: commandSentTimestamp,
positionChangedTimestamp: positionChangedTimestamp
};
// call all the onStateUpdateCallbacks
this.onStateUpdateCallbacks.forEach(callback => callback(callbackParams));
// if recording, store the changes
if(this.isRecording) {
const data = {
previousMotorConfigs : prevMotorConfigs,
newMotorConfigs,
previousMotorConfigsNormalized : this.normalizeMotorConfigs(prevMotorConfigs),
newMotorConfigsNormalized : this.normalizeMotorConfigs(newMotorConfigs),
commandSentTimestamp,
positionChangedTimestamp,
episodeIndex: this.recordingEpisodeIndex,
taskIndex: this.recordingTaskIndex,
}
const episodes = this.recordedMotorPositionEpisodes[this.recordingEpisodeIndex]
episodes.push(data)
}
}
/**
* Adds an onstateupdate callback
* to return a response for the user with motor config states and timestamps
* @param fn Callback function that receives state update parameters
*/
addOnStateUpdateCallback(fn: StateUpdateCallback): void {
this.onStateUpdateCallbacks.push(fn);
}
get isActiveTeleoperator(): boolean {
return this.isActive;
}
}