Spaces:
Running
Running
/** | |
* Keyboard teleoperator for Node.js platform using stdin | |
*/ | |
import { | |
BaseNodeTeleoperator, | |
type TeleoperatorSpecificState, | |
} from "./base-teleoperator.js"; | |
import type { KeyboardControl } from "../types/robot-config.js"; | |
import type { | |
KeyboardTeleoperatorConfig, | |
MotorConfig, | |
TeleoperationState, | |
} from "../types/teleoperation.js"; | |
import type { MotorCommunicationPort } from "../utils/motor-communication.js"; | |
import { | |
readMotorPosition, | |
writeMotorPosition, | |
} from "../utils/motor-communication.js"; | |
/** | |
* Default configuration values for keyboard teleoperator | |
*/ | |
export const KEYBOARD_TELEOPERATOR_DEFAULTS = { | |
stepSize: 8, // Keep browser demo step size | |
updateRate: 120, // Higher frequency for smoother movement (120 Hz) | |
keyTimeout: 150, // Shorter for better single taps, accept some gap on hold | |
} as const; | |
export class KeyboardTeleoperator extends BaseNodeTeleoperator { | |
private keyboardControls: { [key: string]: KeyboardControl } = {}; | |
private updateInterval: NodeJS.Timeout | null = null; | |
private keyStates: { | |
[key: string]: { pressed: boolean; timestamp: number }; | |
} = {}; | |
private onStateUpdate?: (state: TeleoperationState) => void; | |
// Configuration values | |
private readonly stepSize: number; | |
private readonly updateRate: number; | |
private readonly keyTimeout: number; | |
constructor( | |
config: KeyboardTeleoperatorConfig, | |
port: MotorCommunicationPort, | |
motorConfigs: MotorConfig[], | |
keyboardControls: { [key: string]: KeyboardControl }, | |
onStateUpdate?: (state: TeleoperationState) => void | |
) { | |
super(port, motorConfigs); | |
this.keyboardControls = keyboardControls; | |
this.onStateUpdate = onStateUpdate; | |
// Set configuration values | |
this.stepSize = config.stepSize ?? KEYBOARD_TELEOPERATOR_DEFAULTS.stepSize; | |
this.updateRate = | |
config.updateRate ?? KEYBOARD_TELEOPERATOR_DEFAULTS.updateRate; | |
this.keyTimeout = | |
config.keyTimeout ?? KEYBOARD_TELEOPERATOR_DEFAULTS.keyTimeout; | |
} | |
async initialize(): Promise<void> { | |
// Set up stdin for raw keyboard input | |
if (process.stdin.setRawMode) { | |
process.stdin.setRawMode(true); | |
} | |
process.stdin.resume(); | |
process.stdin.setEncoding("utf8"); | |
// Set up keyboard input handler | |
process.stdin.on("data", this.handleKeyboardInput.bind(this)); | |
// Read current motor positions | |
for (const config of this.motorConfigs) { | |
const position = await readMotorPosition(this.port, config.id); | |
if (position !== null) { | |
config.currentPosition = position; | |
} | |
} | |
} | |
start(): void { | |
if (this.isActive) return; | |
this.isActive = true; | |
this.updateInterval = setInterval(() => { | |
this.updateMotorPositions(); | |
}, 1000 / this.updateRate); | |
// Display keyboard controls | |
this.displayControls(); | |
} | |
stop(): void { | |
if (!this.isActive) return; | |
this.isActive = false; | |
if (this.updateInterval) { | |
clearInterval(this.updateInterval); | |
this.updateInterval = null; | |
} | |
// Clear all key states | |
this.keyStates = {}; | |
// Notify of state change | |
if (this.onStateUpdate) { | |
this.onStateUpdate(this.buildTeleoperationState()); | |
} | |
} | |
getState(): TeleoperatorSpecificState { | |
return { | |
keyStates: { ...this.keyStates }, | |
}; | |
} | |
updateKeyState(key: string, pressed: boolean): void { | |
this.keyStates[key] = { | |
pressed, | |
timestamp: Date.now(), | |
}; | |
} | |
private handleKeyboardInput(key: string): void { | |
if (!this.isActive) return; | |
// Handle special keys | |
if (key === "\u0003") { | |
// Ctrl+C | |
process.exit(0); | |
} | |
if (key === "\u001b") { | |
// Escape | |
this.stop(); | |
return; | |
} | |
// Handle regular keys - START IMMEDIATE CONTINUOUS MOVEMENT | |
const keyName = this.mapKeyToName(key); | |
if (keyName && this.keyboardControls[keyName]) { | |
// If key is already active, just refresh timestamp | |
if (this.keyStates[keyName]) { | |
this.keyStates[keyName].timestamp = Date.now(); | |
} else { | |
// New key press - start continuous movement immediately | |
this.updateKeyState(keyName, true); | |
// Move immediately on first press (don't wait for interval) | |
this.moveMotorForKey(keyName); | |
} | |
} | |
} | |
private moveMotorForKey(keyName: string): void { | |
const control = this.keyboardControls[keyName]; | |
if (!control || control.motor === "emergency_stop") return; | |
const motorConfig = this.motorConfigs.find((m) => m.name === control.motor); | |
if (!motorConfig) return; | |
// Calculate new position | |
const newPosition = | |
motorConfig.currentPosition + control.direction * this.stepSize; | |
// Apply limits | |
const clampedPosition = Math.max( | |
motorConfig.minPosition, | |
Math.min(motorConfig.maxPosition, newPosition) | |
); | |
// Send motor command immediately | |
writeMotorPosition(this.port, motorConfig.id, Math.round(clampedPosition)) | |
.then(() => { | |
motorConfig.currentPosition = clampedPosition; | |
}) | |
.catch((error) => { | |
console.warn(`Failed to move motor ${motorConfig.id}:`, error); | |
}); | |
} | |
private updateMotorPositions(): void { | |
const now = Date.now(); | |
// Clear timed-out keys | |
Object.keys(this.keyStates).forEach((key) => { | |
if (now - this.keyStates[key].timestamp > this.keyTimeout) { | |
delete this.keyStates[key]; | |
} | |
}); | |
// Process active keys | |
const activeKeys = Object.keys(this.keyStates).filter( | |
(key) => | |
this.keyStates[key].pressed && | |
now - this.keyStates[key].timestamp <= this.keyTimeout | |
); | |
// Emergency stop check | |
if (activeKeys.includes("Escape")) { | |
this.stop(); | |
return; | |
} | |
// Calculate target positions based on active keys | |
const targetPositions: { [motorName: string]: number } = {}; | |
for (const key of activeKeys) { | |
const control = this.keyboardControls[key]; | |
if (!control || control.motor === "emergency_stop") continue; | |
const motorConfig = this.motorConfigs.find( | |
(m) => m.name === control.motor | |
); | |
if (!motorConfig) continue; | |
// Calculate new position | |
const currentTarget = | |
targetPositions[motorConfig.name] ?? motorConfig.currentPosition; | |
const newPosition = currentTarget + control.direction * this.stepSize; | |
// Apply limits | |
targetPositions[motorConfig.name] = Math.max( | |
motorConfig.minPosition, | |
Math.min(motorConfig.maxPosition, newPosition) | |
); | |
} | |
// Send motor commands and update positions | |
Object.entries(targetPositions).forEach(([motorName, targetPosition]) => { | |
const motorConfig = this.motorConfigs.find((m) => m.name === motorName); | |
if (motorConfig && targetPosition !== motorConfig.currentPosition) { | |
writeMotorPosition( | |
this.port, | |
motorConfig.id, | |
Math.round(targetPosition) | |
) | |
.then(() => { | |
motorConfig.currentPosition = targetPosition; | |
}) | |
.catch((error) => { | |
console.warn( | |
`Failed to write motor ${motorConfig.id} position:`, | |
error | |
); | |
}); | |
} | |
}); | |
} | |
private mapKeyToName(key: string): string | null { | |
// Map stdin input to key names | |
const keyMap: { [key: string]: string } = { | |
"\u001b[A": "ArrowUp", | |
"\u001b[B": "ArrowDown", | |
"\u001b[C": "ArrowRight", | |
"\u001b[D": "ArrowLeft", | |
w: "w", | |
s: "s", | |
a: "a", | |
d: "d", | |
q: "q", | |
e: "e", | |
o: "o", | |
c: "c", | |
}; | |
return keyMap[key] || null; | |
} | |
private displayControls(): void { | |
console.log("\n=== Robot Teleoperation Controls ==="); | |
console.log("Arrow Keys: Shoulder pan/lift"); | |
console.log("WASD: Elbow flex / Wrist flex"); | |
console.log("Q/E: Wrist roll"); | |
console.log("O/C: Gripper open/close"); | |
console.log("ESC: Emergency stop"); | |
console.log("Ctrl+C: Exit"); | |
console.log("=====================================\n"); | |
} | |
private buildTeleoperationState(): TeleoperationState { | |
return { | |
isActive: this.isActive, | |
motorConfigs: [...this.motorConfigs], | |
lastUpdate: Date.now(), | |
keyStates: { ...this.keyStates }, | |
}; | |
} | |
} | |