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(); | |
} | |
} | |