blanchon's picture
Mostly UI Update
18b0fa5
import type {
MasterDriver,
ConnectionStatus,
RobotCommand,
CommandSequence,
USBMasterConfig,
CommandCallback,
SequenceCallback,
StatusChangeCallback,
UnsubscribeFn
} from "$lib/types/robotDriver";
import { getRobotPollingConfig, getDataProcessingConfig } from "$lib/configs/performanceConfig";
import { scsServoSDKUnlock } from "feetech.js";
/**
* USB Master Driver
* Reads positions from a physical USB robot and forwards movements to slaves
* Allows physical robot to act as a master controller for digital twins and other robots
*/
export class USBMaster implements MasterDriver {
readonly type = "master" as const;
readonly id: string;
readonly name: string;
private _status: ConnectionStatus = { isConnected: false };
private config: USBMasterConfig;
// Robot joint configuration
private jointIds: number[] = [1, 2, 3, 4, 5, 6]; // Default servo IDs for so-arm100
private jointNames: string[] = ["Rotation", "Pitch", "Elbow", "Wrist_Pitch", "Wrist_Roll", "Jaw"];
private lastPositions: Map<number, number> = new Map();
private restPositions: Map<number, number> = new Map(); // Store initial rest positions
private positionHistory: Map<number, number[]> = new Map();
// Polling control
private pollingInterval?: number;
private isPolling = false;
private isPaused = false;
private isReading = false; // Prevent concurrent reads
// Event callbacks
private commandCallbacks: CommandCallback[] = [];
private sequenceCallbacks: SequenceCallback[] = [];
private statusCallbacks: StatusChangeCallback[] = [];
constructor(config: USBMasterConfig) {
this.config = config;
this.id = `usb-master-${Date.now()}`;
this.name = `USB Master Robot`;
// Initialize position tracking
this.jointIds.forEach((id) => {
this.lastPositions.set(id, 2048); // Center position (will be updated on connect)
this.restPositions.set(id, 2048); // Rest position baseline (will be updated on connect)
this.positionHistory.set(id, []);
});
console.log(`🔌 Created USBMaster with ${this.jointIds.length} joints (unlock mode)`);
}
get status(): ConnectionStatus {
return this._status;
}
async connect(): Promise<void> {
console.log(`🔌 Connecting ${this.name} to USB robot...`);
try {
// Connect to USB robot
await scsServoSDKUnlock.connect({
baudRate: this.config.baudRate || 1000000,
protocolEnd: 0
});
// Read initial positions
await this.readInitialPositions();
this._status = {
isConnected: true,
lastConnected: new Date(),
error: undefined
};
this.notifyStatusChange();
console.log(`✅ ${this.name} connected successfully`);
} catch (error) {
this._status = {
isConnected: false,
error: `Connection failed: ${error}`
};
this.notifyStatusChange();
throw error;
}
}
async disconnect(): Promise<void> {
console.log(`🔌 Disconnecting ${this.name}...`);
this.stopPolling();
if (scsServoSDKUnlock && this._status.isConnected) {
try {
await scsServoSDKUnlock.disconnect();
} catch (error) {
console.warn("Error disconnecting USB robot:", error);
}
}
this._status = { isConnected: false };
this.notifyStatusChange();
console.log(`✅ ${this.name} disconnected`);
}
async start(): Promise<void> {
if (!this._status.isConnected) {
throw new Error("Cannot start: USB master not connected");
}
console.log(`🎮 Starting USB master polling...`);
this.startPolling();
}
async stop(): Promise<void> {
console.log(`⏹️ Stopping USB master polling...`);
this.stopPolling();
}
async pause(): Promise<void> {
console.log(`⏸️ Pausing USB master polling...`);
this.isPaused = true;
}
async resume(): Promise<void> {
console.log(`▶️ Resuming USB master polling...`);
this.isPaused = false;
}
// Event subscription methods
onCommand(callback: CommandCallback): UnsubscribeFn {
this.commandCallbacks.push(callback);
return () => {
const index = this.commandCallbacks.indexOf(callback);
if (index >= 0) {
this.commandCallbacks.splice(index, 1);
}
};
}
onSequence(callback: SequenceCallback): UnsubscribeFn {
this.sequenceCallbacks.push(callback);
return () => {
const index = this.sequenceCallbacks.indexOf(callback);
if (index >= 0) {
this.sequenceCallbacks.splice(index, 1);
}
};
}
onStatusChange(callback: StatusChangeCallback): UnsubscribeFn {
this.statusCallbacks.push(callback);
return () => {
const index = this.statusCallbacks.indexOf(callback);
if (index >= 0) {
this.statusCallbacks.splice(index, 1);
}
};
}
// Private methods
private async readInitialPositions(): Promise<void> {
if (!scsServoSDKUnlock) return;
try {
console.log("📖 Reading initial REST positions from USB robot (individual reads)...");
let successCount = 0;
for (const servoId of this.jointIds) {
try {
const position = await scsServoSDKUnlock.readPosition(servoId);
// Store as both last position and rest position baseline
this.lastPositions.set(servoId, position);
this.restPositions.set(servoId, position);
console.log(`Joint ${servoId}: REST position = ${position} (servo units)`);
successCount++;
} catch (err) {
console.warn(`Failed to read servo ${servoId}:`, err);
// Keep default center positions for failed reads
}
}
console.log(`📖 Successfully read ${successCount}/${this.jointIds.length} REST positions`);
console.log("🏠 Robot is now calibrated to REST position baseline");
} catch (error) {
console.warn("Could not read initial positions:", error);
console.log("🏠 Using default center positions as REST baseline");
}
}
private startPolling(): void {
if (this.isPolling) return;
this.isPolling = true;
// Use performance config instead of hardcoded value
const pollInterval =
this.config.pollInterval || getRobotPollingConfig().USB_MASTER_POLL_INTERVAL_MS;
this.pollingInterval = setInterval(async () => {
if (this.isPaused || !this._status.isConnected || this.isReading) return;
try {
await this.pollRobotPositions();
} catch (error) {
console.error("Error polling robot positions:", error);
// Handle connection errors
if (error instanceof Error && error.message?.includes("Not connected")) {
this._status = {
isConnected: false,
error: `Connection lost: ${error.message}`
};
this.notifyStatusChange();
this.stopPolling();
}
}
}, pollInterval);
console.log(`🔄 USB master polling started (${pollInterval}ms interval)`);
}
private stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = undefined;
}
this.isPolling = false;
}
private async pollRobotPositions(): Promise<void> {
if (!scsServoSDKUnlock || this.isReading) return;
this.isReading = true; // Prevent concurrent reads
try {
const currentPositions: Map<number, number> = new Map();
const changedJoints: { name: string; value: number }[] = [];
let validReads = 0;
// Read all servo positions
for (const servoId of this.jointIds) {
try {
const position = await scsServoSDKUnlock.readPosition(servoId);
if (position !== null && position !== undefined) {
currentPositions.set(servoId, position);
validReads++;
}
} catch {
// Skip individual servo read errors
}
}
currentPositions.forEach((position: number, servoId: number) => {
// Process all readings - position 0 is valid
const lastPosition = this.lastPositions.get(servoId) || 2048;
// Use performance config for threshold
const threshold = getDataProcessingConfig().ENABLE_SMOOTHING
? getRobotPollingConfig().POSITION_CHANGE_THRESHOLD
: 1; // Maximum sensitivity when smoothing disabled
if (Math.abs(position - lastPosition) > threshold) {
const jointIndex = this.jointIds.indexOf(servoId);
if (jointIndex >= 0) {
const jointName = this.jointNames[jointIndex];
const degrees = this.servoToRelativeDegrees(position, servoId);
console.log(
`🎯 Joint ${jointName} moved: ${lastPosition}${position} (${degrees.toFixed(1)}°)`
);
// Apply smoothing if enabled in performance config
if (this.config.smoothing && getDataProcessingConfig().ENABLE_SMOOTHING) {
const history = this.positionHistory.get(servoId) || [];
history.push(degrees);
const maxHistory = getDataProcessingConfig().SMOOTHING_HISTORY_SIZE;
if (history.length > maxHistory) history.shift();
this.positionHistory.set(servoId, history);
// Use average of recent positions
const smoothedDegrees = history.reduce((a, b) => a + b, 0) / history.length;
changedJoints.push({ name: jointName, value: smoothedDegrees });
} else {
changedJoints.push({ name: jointName, value: degrees });
}
this.lastPositions.set(servoId, position);
}
}
});
// Log polling status occasionally (less frequent with faster polling)
if (Date.now() % 5000 < getRobotPollingConfig().USB_MASTER_POLL_INTERVAL_MS) {
console.log(
`🔄 USB polling: ${validReads}/${this.jointIds.length} joints read successfully`
);
}
// If there are changes, emit command
if (changedJoints.length > 0) {
const command: RobotCommand = {
timestamp: Date.now(),
joints: changedJoints,
metadata: { source: "usb_master_polling" }
};
console.log(`📤 Emitting command with ${changedJoints.length} joint changes`);
this.notifyCommand([command]);
}
} catch (error) {
console.error("Polling error:", error);
// Don't re-throw port access errors, just log them
if (error instanceof Error && error.message?.includes("Releasing Default reader")) {
console.warn("Serial port access conflict, skipping this poll cycle");
return;
}
throw error; // Re-throw other errors
} finally {
// Always release the reading lock
this.isReading = false;
}
}
private servoToRelativeDegrees(servoPosition: number, servoId: number): number {
// Get the rest position for this servo
const restPosition = this.restPositions.get(servoId) || 2048;
// Calculate relative movement from rest position
const deltaServoUnits = servoPosition - restPosition;
// Convert servo units to degrees (assuming full range 0-4095 = 360°)
const relativeDegrees = (deltaServoUnits / 4095) * 360;
return relativeDegrees;
}
private notifyCommand(commands: RobotCommand[]): void {
this.commandCallbacks.forEach((callback) => {
try {
callback(commands);
} catch (error) {
console.error("Error in command callback:", error);
}
});
}
private notifySequence(sequence: CommandSequence): void {
this.sequenceCallbacks.forEach((callback) => {
try {
callback(sequence);
} catch (error) {
console.error("Error in sequence callback:", error);
}
});
}
private notifyStatusChange(): void {
this.statusCallbacks.forEach((callback) => {
try {
callback(this._status);
} catch (error) {
console.error("Error in status callback:", error);
}
});
}
}