Spaces:
Running
Running
import type { ConnectionStatus, USBDriverConfig } from '../models.js'; | |
import { CalibrationState } from '../calibration/CalibrationState.svelte.js'; | |
import { ScsServoSDK } from 'feetech.js'; | |
import { ROBOT_CONFIG } from '../config.js'; | |
export abstract class USBServoDriver { | |
readonly id: string; | |
readonly name: string; | |
readonly config: USBDriverConfig; | |
protected _status: ConnectionStatus = { isConnected: false }; | |
protected statusCallbacks: ((status: ConnectionStatus) => void)[] = []; | |
protected scsServoSDK: ScsServoSDK | null = null; | |
// Calibration state - directly embedded | |
readonly calibrationState: CalibrationState; | |
// Calibration polling | |
private calibrationPollingInterval: ReturnType<typeof setInterval> | null = null; | |
// Joint to servo ID mapping for SO-100 arm | |
protected readonly jointToServoMap = { | |
"Rotation": 1, | |
"Pitch": 2, | |
"Elbow": 3, | |
"Wrist_Pitch": 4, | |
"Wrist_Roll": 5, | |
"Jaw": 6 | |
}; | |
constructor(config: USBDriverConfig, driverType: string) { | |
this.config = config; | |
this.id = `usb-${driverType}-${Date.now()}`; | |
this.name = `USB ${driverType}`; | |
this.calibrationState = new CalibrationState(); | |
} | |
get status(): ConnectionStatus { | |
return this._status; | |
} | |
get needsCalibration(): boolean { | |
return this.calibrationState.needsCalibration; | |
} | |
get isCalibrating(): boolean { | |
return this.calibrationState.isCalibrating; | |
} | |
get isCalibrated(): boolean { | |
return this.calibrationState.isCalibrated; | |
} | |
// Type guard to check if a driver is a USB driver | |
static isUSBDriver(driver: any): driver is USBServoDriver { | |
return driver && typeof driver.calibrationState === 'object' && | |
typeof driver.needsCalibration === 'boolean' && | |
typeof driver.isCalibrated === 'boolean' && | |
typeof driver.startCalibration === 'function'; | |
} | |
// Type-safe method to get calibration interface | |
getCalibrationInterface(): { | |
needsCalibration: boolean; | |
isCalibrating: boolean; | |
isCalibrated: boolean; | |
startCalibration: () => Promise<void>; | |
completeCalibration: () => Promise<Record<string, number>>; | |
skipCalibration: () => void; | |
cancelCalibration: () => void; | |
onCalibrationCompleteWithPositions: (callback: (positions: Record<string, number>) => void) => () => void; | |
} { | |
return { | |
needsCalibration: this.needsCalibration, | |
isCalibrating: this.isCalibrating, | |
isCalibrated: this.isCalibrated, | |
startCalibration: () => this.startCalibration(), | |
completeCalibration: () => this.completeCalibration(), | |
skipCalibration: () => this.skipCalibration(), | |
cancelCalibration: () => this.cancelCalibration(), | |
onCalibrationCompleteWithPositions: (callback) => this.onCalibrationCompleteWithPositions(callback) | |
}; | |
} | |
// Abstract methods that subclasses must implement | |
abstract connect(): Promise<void>; | |
abstract disconnect(): Promise<void>; | |
// Common connection logic | |
protected async connectToUSB(): Promise<void> { | |
if (this._status.isConnected) { | |
console.log(`[${this.name}] Already connected`); | |
return; | |
} | |
try { | |
console.log(`[${this.name}] Connecting...`); | |
// Create a new SDK instance for this driver instead of using the singleton | |
// This allows multiple drivers to connect to different ports simultaneously | |
this.scsServoSDK = new ScsServoSDK(); | |
await this.scsServoSDK.connect({ | |
baudRate: this.config.baudRate || ROBOT_CONFIG.usb.baudRate, | |
protocolEnd: 0 // STS/SMS protocol | |
}); | |
this._status = { isConnected: true, lastConnected: new Date() }; | |
this.notifyStatusChange(); | |
console.log(`[${this.name}] Connected successfully`); | |
// Debug: Log SDK instance methods to identify the issue | |
console.log(`[${this.name}] SDK instance methods:`, Object.getOwnPropertyNames(this.scsServoSDK)); | |
console.log(`[${this.name}] SDK prototype methods:`, Object.getOwnPropertyNames(Object.getPrototypeOf(this.scsServoSDK))); | |
console.log(`[${this.name}] writeTorqueEnable available:`, typeof this.scsServoSDK.writeTorqueEnable); | |
console.log(`[${this.name}] syncReadPositions available:`, typeof this.scsServoSDK.syncReadPositions); | |
} catch (error) { | |
console.error(`[${this.name}] Connection failed:`, error); | |
this._status = { isConnected: false, error: `Connection failed: ${error}` }; | |
this.notifyStatusChange(); | |
throw error; | |
} | |
} | |
protected async disconnectFromUSB(): Promise<void> { | |
if (this.scsServoSDK) { | |
try { | |
await this.unlockAllServos(); | |
await this.scsServoSDK.disconnect(); | |
} catch (error) { | |
console.warn(`[${this.name}] Error during disconnect:`, error); | |
} | |
this.scsServoSDK = null; | |
} | |
this._status = { isConnected: false }; | |
this.notifyStatusChange(); | |
console.log(`[${this.name}] Disconnected`); | |
} | |
// Calibration methods | |
async startCalibration(): Promise<void> { | |
if (!this._status.isConnected) { | |
await this.connectToUSB(); | |
} | |
if (!this._status.isConnected) { | |
throw new Error('Cannot start calibration: not connected'); | |
} | |
console.log(`[${this.name}] Starting calibration...`); | |
this.calibrationState.startCalibration(); | |
// Unlock servos for manual movement during calibration | |
await this.unlockAllServos(); | |
// Start polling positions during calibration | |
await this.startCalibrationPolling(); | |
} | |
async completeCalibration(): Promise<Record<string, number>> { | |
if (!this.isCalibrating) { | |
throw new Error('Not currently calibrating'); | |
} | |
// Stop polling | |
this.stopCalibrationPolling(); | |
// Read final positions | |
const finalPositions = await this.readCurrentPositions(); | |
// Complete calibration state | |
this.calibrationState.completeCalibration(); | |
console.log(`[${this.name}] Calibration completed`); | |
return finalPositions; | |
} | |
skipCalibration(): void { | |
// Stop polling if active | |
this.stopCalibrationPolling(); | |
this.calibrationState.skipCalibration(); | |
} | |
async setPredefinedCalibration(): Promise<void> { | |
// Stop polling if active | |
this.stopCalibrationPolling(); | |
this.skipCalibration(); | |
} | |
// Cancel calibration | |
cancelCalibration(): void { | |
// Stop polling if active | |
this.stopCalibrationPolling(); | |
this.calibrationState.cancelCalibration(); | |
} | |
// Start polling servo positions during calibration | |
private async startCalibrationPolling(): Promise<void> { | |
if (this.calibrationPollingInterval !== null) { | |
return; // Already polling | |
} | |
console.log(`[${this.name}] Starting calibration position polling...`); | |
// Poll positions every 100ms during calibration | |
this.calibrationPollingInterval = setInterval(async () => { | |
if (!this.isCalibrating || !this._status.isConnected || !this.scsServoSDK) { | |
this.stopCalibrationPolling(); | |
return; | |
} | |
try { | |
// Read positions for all servos | |
const servoIds = Object.values(this.jointToServoMap); | |
const positions = await this.scsServoSDK.syncReadPositions(servoIds); | |
// Update calibration state with current positions | |
Object.entries(this.jointToServoMap).forEach(([jointName, servoId]) => { | |
const position = positions.get(servoId); | |
if (position !== undefined) { | |
this.calibrationState.updateCurrentValue(jointName, position); | |
console.debug(`[${this.name}] ${jointName} (servo ${servoId}): ${position}`); | |
} | |
}); | |
} catch (error) { | |
console.warn(`[${this.name}] Calibration polling error:`, error); | |
// Continue polling despite errors - user might be moving servos rapidly | |
} | |
}, 100); // Poll every 100ms | |
} | |
// Stop polling servo positions | |
private stopCalibrationPolling(): void { | |
if (this.calibrationPollingInterval !== null) { | |
clearInterval(this.calibrationPollingInterval); | |
this.calibrationPollingInterval = null; | |
console.log(`[${this.name}] Stopped calibration position polling`); | |
} | |
} | |
// Servo position reading (for calibration) | |
async readCurrentPositions(): Promise<Record<string, number>> { | |
if (!this.scsServoSDK || !this._status.isConnected) { | |
throw new Error('Cannot read positions: not connected'); | |
} | |
const positions: Record<string, number> = {}; | |
try { | |
const servoIds = Object.values(this.jointToServoMap); | |
const servoPositions = await this.scsServoSDK.syncReadPositions(servoIds); | |
Object.entries(this.jointToServoMap).forEach(([jointName, servoId]) => { | |
const position = servoPositions.get(servoId); | |
if (position !== undefined) { | |
positions[jointName] = position; | |
// Update calibration state with current position | |
this.calibrationState.updateCurrentValue(jointName, position); | |
} | |
}); | |
} catch (error) { | |
console.error(`[${this.name}] Error reading positions:`, error); | |
throw error; | |
} | |
return positions; | |
} | |
// Value conversion methods | |
normalizeValue(rawValue: number, jointName: string): number { | |
if (!this.isCalibrated) { | |
throw new Error('Cannot normalize value: not calibrated'); | |
} | |
return this.calibrationState.normalizeValue(rawValue, jointName); | |
} | |
denormalizeValue(normalizedValue: number, jointName: string): number { | |
if (!this.isCalibrated) { | |
throw new Error('Cannot denormalize value: not calibrated'); | |
} | |
return this.calibrationState.denormalizeValue(normalizedValue, jointName); | |
} | |
// Servo control methods | |
protected async lockAllServos(): Promise<void> { | |
if (!this.scsServoSDK) return; | |
try { | |
console.log(`[${this.name}] Locking all servos...`); | |
const servoIds = Object.values(this.jointToServoMap); | |
for (const servoId of servoIds) { | |
try { | |
// Check if writeTorqueEnable method exists before calling | |
if (typeof this.scsServoSDK.writeTorqueEnable !== 'function') { | |
console.warn(`[${this.name}] writeTorqueEnable method not available on SDK instance for servo ${servoId}`); | |
continue; | |
} | |
await this.scsServoSDK.writeTorqueEnable(servoId, true); | |
await new Promise(resolve => setTimeout(resolve, ROBOT_CONFIG.usb.servoWriteDelay)); | |
} catch (error) { | |
console.warn(`[${this.name}] Failed to lock servo ${servoId}:`, error); | |
} | |
} | |
console.log(`[${this.name}] All servos locked`); | |
} catch (error) { | |
console.error(`[${this.name}] Error locking servos:`, error); | |
} | |
} | |
protected async unlockAllServos(): Promise<void> { | |
if (!this.scsServoSDK) return; | |
try { | |
console.log(`[${this.name}] Unlocking all servos...`); | |
const servoIds = Object.values(this.jointToServoMap); | |
for (const servoId of servoIds) { | |
try { | |
// Check if writeTorqueEnable method exists before calling | |
if (typeof this.scsServoSDK.writeTorqueEnable !== 'function') { | |
console.warn(`[${this.name}] writeTorqueEnable method not available on SDK instance for servo ${servoId}`); | |
continue; | |
} | |
await this.scsServoSDK.writeTorqueEnable(servoId, false); | |
await new Promise(resolve => setTimeout(resolve, ROBOT_CONFIG.usb.servoWriteDelay)); | |
} catch (error) { | |
console.warn(`[${this.name}] Failed to unlock servo ${servoId}:`, error); | |
} | |
} | |
console.log(`[${this.name}] All servos unlocked`); | |
} catch (error) { | |
console.error(`[${this.name}] Error unlocking servos:`, error); | |
} | |
} | |
// Event handlers | |
onStatusChange(callback: (status: ConnectionStatus) => void): () => void { | |
this.statusCallbacks.push(callback); | |
return () => { | |
const index = this.statusCallbacks.indexOf(callback); | |
if (index >= 0) { | |
this.statusCallbacks.splice(index, 1); | |
} | |
}; | |
} | |
protected notifyStatusChange(): void { | |
this.statusCallbacks.forEach(callback => { | |
try { | |
callback(this._status); | |
} catch (error) { | |
console.error(`[${this.name}] Error in status callback:`, error); | |
} | |
}); | |
} | |
// Register callback for calibration completion with positions | |
onCalibrationCompleteWithPositions(callback: (positions: Record<string, number>) => void): () => void { | |
return this.calibrationState.onCalibrationCompleteWithPositions(callback); | |
} | |
// Sync robot joint positions using normalized values from calibration | |
syncRobotPositions(finalPositions: Record<string, number>, updateRobotCallback?: (jointName: string, normalizedValue: number) => void): void { | |
if (!updateRobotCallback) return; | |
console.log(`[${this.name}] 🔄 Syncing robot to final calibration positions...`); | |
Object.entries(finalPositions).forEach(([jointName, rawPosition]) => { | |
try { | |
// Convert raw servo position to normalized value using calibration | |
const normalizedValue = this.normalizeValue(rawPosition, jointName); | |
// Clamp to appropriate normalized range based on joint type | |
let clampedValue: number; | |
if (jointName.toLowerCase() === 'jaw' || jointName.toLowerCase() === 'gripper') { | |
clampedValue = Math.max(0, Math.min(100, normalizedValue)); | |
} else { | |
clampedValue = Math.max(-100, Math.min(100, normalizedValue)); | |
} | |
console.log(`[${this.name}] ${jointName}: ${rawPosition} (raw) -> ${normalizedValue.toFixed(1)} -> ${clampedValue.toFixed(1)} (normalized)`); | |
// Update robot joint through callback | |
updateRobotCallback(jointName, clampedValue); | |
} catch (error) { | |
console.warn(`[${this.name}] Failed to sync position for joint ${jointName}:`, error); | |
} | |
}); | |
console.log(`[${this.name}] ✅ Robot synced to calibration positions`); | |
} | |
// Cleanup | |
async destroy(): Promise<void> { | |
this.stopCalibrationPolling(); | |
await this.disconnect(); | |
this.calibrationState.destroy(); | |
} | |
} |