Spaces:
Running
Running
/** | |
* Keyboard teleoperator for Web platform | |
*/ | |
import { | |
BaseWebTeleoperator, | |
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, // Position units per keypress (smooth responsive control) | |
updateRate: 60, // Control loop FPS (60 Hz for smooth updates) | |
keyTimeout: 10000, // Key state timeout in ms (10 seconds for virtual buttons) | |
} as const; | |
export class KeyboardTeleoperator extends BaseWebTeleoperator { | |
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> { | |
// 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); | |
} | |
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 UI 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(), | |
}; | |
} | |
/** | |
* Move motor to exact position (for sliders and direct control) | |
* This ensures sliders update the same motor configs that the UI displays | |
*/ | |
async moveMotor(motorName: string, targetPosition: number): Promise<boolean> { | |
const motorConfig = this.motorConfigs.find((m) => m.name === motorName); | |
if (!motorConfig) return false; | |
const clampedPosition = Math.max( | |
motorConfig.minPosition, | |
Math.min(motorConfig.maxPosition, targetPosition) | |
); | |
try { | |
await writeMotorPosition( | |
this.port, | |
motorConfig.id, | |
Math.round(clampedPosition) | |
); | |
motorConfig.currentPosition = clampedPosition; | |
// Notify UI of position change for immediate slider update | |
if (this.onStateUpdate) { | |
this.onStateUpdate(this.buildTeleoperationState()); | |
} | |
return true; | |
} catch (error) { | |
console.warn(`Failed to move motor ${motorName}:`, error); | |
return false; | |
} | |
} | |
private buildTeleoperationState(): TeleoperationState { | |
return { | |
isActive: this.isActive, | |
motorConfigs: [...this.motorConfigs], | |
lastUpdate: Date.now(), | |
keyStates: { ...this.keyStates }, | |
}; | |
} | |
/** | |
* IMPORTANT: This method implements the WORKING keyboard control logic. | |
* | |
* β οΈ DO NOT MODIFY THIS LOGIC! β οΈ | |
* | |
* This simple approach works perfectly: | |
* - If key is pressed β apply movement every update cycle | |
* - stepSize: 8 units per cycle at 60Hz = smooth, responsive control | |
* - Single taps work naturally (brief key press = few cycles = small movement) | |
* - Held keys work naturally (continuous press = continuous movement) | |
* | |
* Previous "improvements" that BROKE this: | |
* β Trying to detect "first press" vs "held" - breaks everything | |
* β Adding delays/thresholds - makes it clunky | |
* β Event-driven immediate movement - causes multiple applications | |
* β Higher stepSize values - too large jumps | |
* | |
* Keep it simple - this works! | |
*/ | |
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 | |
// SIMPLE RULE: If key is pressed β apply movement (works perfectly!) | |
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 | |
); | |
}); | |
} | |
}); | |
} | |
} | |