Spaces:
Running
Running
import type { RobotState } from "$lib/types/robot"; | |
import type { | |
MasterDriver, | |
SlaveDriver, | |
DriverJointState, | |
ConnectionStatus, | |
RobotCommand, | |
CommandSequence, | |
UnsubscribeFn | |
} from "$lib/types/robotDriver"; | |
import type IUrdfJoint from "@/components/3d/robot/URDF/interfaces/IUrdfJoint"; | |
import { getRobotPollingConfig } from "$lib/configs/performanceConfig"; | |
/** | |
* Enhanced joint state that combines URDF data with driver state | |
*/ | |
export interface ManagedJointState { | |
// URDF properties | |
name: string; | |
urdfJoint: IUrdfJoint; | |
servoId?: number; | |
// Current state | |
virtualValue: number; // What the UI shows/controls | |
realValue?: number; // What the physical robot reports | |
commandedValue: number; // What was last commanded by master/manual | |
isActive: boolean; // Whether this joint is being controlled | |
// Calibration state | |
calibrationOffset: number; // Offset to apply: realValue = rawValue + offset | |
restPosition: number; // Expected position for this joint in rest mode | |
// Sync state | |
lastVirtualUpdate: Date; | |
lastRealUpdate?: Date; | |
lastCommandUpdate?: Date; | |
syncError?: string; | |
} | |
/** | |
* Calibration state for robot | |
*/ | |
export interface CalibrationState { | |
isCalibrated: boolean; | |
calibrationTime?: Date; | |
offsets: { [jointName: string]: number }; | |
} | |
/** | |
* Control source information | |
*/ | |
export interface ControlState { | |
hasActiveMaster: boolean; | |
masterName?: string; | |
manualControlEnabled: boolean; | |
lastCommandSource: "master" | "manual" | "none"; | |
commandQueue: RobotCommand[]; | |
} | |
/** | |
* Robot instance that manages URDF visualization and master-slave connections | |
*/ | |
export class Robot { | |
readonly id: string; | |
readonly robotState: RobotState; | |
// Joint management | |
private _joints = $state<ManagedJointState[]>([]); | |
// Master driver (command source) | |
private _master = $state<MasterDriver | undefined>(undefined); | |
private _masterStatus = $state<ConnectionStatus>({ isConnected: false }); | |
// Slave drivers (execution targets) | |
private _slaves = $state<SlaveDriver[]>([]); | |
private _slaveStatuses = $state<Map<string, ConnectionStatus>>(new Map()); | |
// Control state | |
private _controlState = $state<ControlState>({ | |
hasActiveMaster: false, | |
manualControlEnabled: true, | |
lastCommandSource: "none", | |
commandQueue: [] | |
}); | |
// Calibration management | |
private _calibrationState = $state<CalibrationState>({ | |
isCalibrated: false, | |
offsets: {} | |
}); | |
// Sync control - use performance config | |
private syncIntervalId?: number; | |
private readonly syncInterval = getRobotPollingConfig().ROBOT_SYNC_INTERVAL_MS; | |
// Event subscriptions | |
private unsubscribeCallbacks: UnsubscribeFn[] = []; | |
// Reactive getters | |
get joints() { | |
return this._joints; | |
} | |
get master() { | |
return this._master; | |
} | |
get masterStatus() { | |
return this._masterStatus; | |
} | |
get slaves() { | |
return this._slaves; | |
} | |
get slaveStatuses() { | |
return this._slaveStatuses; | |
} | |
get controlState() { | |
return this._controlState; | |
} | |
get calibrationState() { | |
return this._calibrationState; | |
} | |
get isCalibrated() { | |
return this._calibrationState.isCalibrated; | |
} | |
get activeJoints() { | |
return this._joints.filter((joint) => joint.isActive); | |
} | |
get connectedSlaves() { | |
return this._slaves.filter((slave) => this._slaveStatuses.get(slave.id)?.isConnected); | |
} | |
get manualControlEnabled() { | |
return this._controlState.manualControlEnabled && !this._controlState.hasActiveMaster; | |
} | |
constructor(id: string, robotState: RobotState) { | |
this.id = id; | |
this.robotState = robotState; | |
this.initializeJoints(); | |
} | |
/** | |
* Initialize joint states from URDF | |
*/ | |
private initializeJoints(): void { | |
const servoMap = this.robotState.urdfConfig.jointNameIdMap || {}; | |
const restPositions = this.robotState.urdfConfig.restPosition || {}; | |
this._joints = this.robotState.robot.joints | |
.filter((urdfJoint) => urdfJoint.type === "revolute" || urdfJoint.type === "continuous") | |
.map((urdfJoint) => { | |
const jointName = urdfJoint.name || ""; | |
const restPosition = restPositions[jointName] || 0; | |
// Initialize URDF joint rotation to rest position | |
if (urdfJoint.type === "revolute") { | |
const radians = (restPosition * Math.PI) / 180; | |
const axis = urdfJoint.axis_xyz || [0, 0, 1]; | |
urdfJoint.rotation = [radians * axis[0], radians * axis[1], radians * axis[2]]; | |
} | |
return { | |
name: jointName, | |
urdfJoint, | |
servoId: servoMap[jointName], | |
virtualValue: restPosition, | |
commandedValue: restPosition, | |
realValue: undefined, | |
isActive: !!servoMap[jointName], | |
lastVirtualUpdate: new Date(), | |
lastRealUpdate: undefined, | |
lastCommandUpdate: undefined, | |
syncError: undefined, | |
calibrationOffset: 0, | |
restPosition | |
}; | |
}); | |
console.log(`Initialized ${this._joints.length} joints for robot ${this.id}`); | |
} | |
// ============= MASTER MANAGEMENT ============= | |
/** | |
* Connect a master driver (command source) | |
*/ | |
async setMaster(master: MasterDriver): Promise<void> { | |
// Disconnect existing master | |
if (this._master) { | |
await this.removeMaster(); | |
} | |
this._master = master; | |
this._masterStatus = master.status; | |
// Subscribe to master events | |
const statusUnsub = master.onStatusChange((status) => { | |
this._masterStatus = status; | |
this._controlState.hasActiveMaster = status.isConnected; | |
this._controlState.manualControlEnabled = !status.isConnected; | |
}); | |
const commandUnsub = master.onCommand((commands) => { | |
this.handleMasterCommands(commands); | |
}); | |
const sequenceUnsub = master.onSequence((sequence) => { | |
this.handleMasterSequence(sequence); | |
}); | |
this.unsubscribeCallbacks.push(statusUnsub, commandUnsub, sequenceUnsub); | |
// Update control state | |
this._controlState.hasActiveMaster = master.status.isConnected; | |
this._controlState.manualControlEnabled = !master.status.isConnected; | |
this._controlState.masterName = master.name; | |
// Start the master (for masters that need to poll/generate commands) | |
if (master.status.isConnected) { | |
await master.start(); | |
} | |
console.log(`Master ${master.name} connected to robot ${this.id}`); | |
} | |
/** | |
* Remove the current master | |
*/ | |
async removeMaster(): Promise<void> { | |
if (!this._master) return; | |
if (this._master.status.isConnected) { | |
// Stop the master first, then disconnect | |
try { | |
await this._master.stop(); | |
} catch (error) { | |
console.warn(`Error stopping master ${this._master.name}:`, error); | |
} | |
await this._master.disconnect(); | |
} | |
this._master = undefined; | |
this._masterStatus = { isConnected: false }; | |
this._controlState.hasActiveMaster = false; | |
this._controlState.manualControlEnabled = true; | |
this._controlState.masterName = undefined; | |
console.log(`Master removed from robot ${this.id}. Manual control restored.`); | |
} | |
// ============= SLAVE MANAGEMENT ============= | |
/** | |
* Add a slave driver (execution target) | |
*/ | |
async addSlave(slave: SlaveDriver): Promise<void> { | |
// Check if slave already exists | |
if (this._slaves.find((s) => s.id === slave.id)) { | |
throw new Error(`Slave ${slave.id} already connected to robot ${this.id}`); | |
} | |
// Connect the slave | |
await slave.connect(); | |
this._slaves.push(slave); | |
this._slaveStatuses.set(slave.id, slave.status); | |
// Subscribe to slave events | |
const statusUnsub = slave.onStatusChange((status) => { | |
this._slaveStatuses.set(slave.id, status); | |
}); | |
const stateUnsub = slave.onStateUpdate((driverStates) => { | |
this.updateFromSlave(slave.id, driverStates); | |
}); | |
this.unsubscribeCallbacks.push(statusUnsub, stateUnsub); | |
// Move slave to rest position for safety | |
await this.moveSlaveToRestPosition(slave); | |
console.log(`Slave ${slave.name} (${slave.id}) connected to robot ${this.id}`); | |
} | |
/** | |
* Remove a slave driver | |
*/ | |
async removeSlave(slaveId: string): Promise<void> { | |
const slaveIndex = this._slaves.findIndex((s) => s.id === slaveId); | |
if (slaveIndex === -1) return; | |
const slave = this._slaves[slaveIndex]; | |
// Move to rest position before disconnection | |
if (this._slaveStatuses.get(slaveId)?.isConnected) { | |
try { | |
await this.moveSlaveToRestPosition(slave); | |
await new Promise((resolve) => setTimeout(resolve, 500)); | |
} catch (error) { | |
console.warn(`Failed to move slave ${slaveId} to rest position:`, error); | |
} | |
} | |
// Disconnect slave | |
if (slave.status.isConnected) { | |
await slave.disconnect(); | |
} | |
// Remove from arrays | |
this._slaves.splice(slaveIndex, 1); | |
this._slaveStatuses.delete(slaveId); | |
console.log(`Slave ${slaveId} removed from robot ${this.id}`); | |
} | |
// ============= COMMAND HANDLING ============= | |
/** | |
* Handle commands from master | |
*/ | |
private async handleMasterCommands(commands: RobotCommand[]): Promise<void> { | |
console.log(`Received ${commands.length} commands from master for robot ${this.id}`); | |
// Update control state | |
this._controlState.lastCommandSource = "master"; | |
this._controlState.commandQueue = [...commands]; | |
// Execute on all connected slaves | |
const executePromises = this.connectedSlaves.map((slave) => | |
slave | |
.executeCommands(commands) | |
.catch((error) => console.error(`Slave ${slave.id} failed to execute commands:`, error)) | |
); | |
await Promise.allSettled(executePromises); | |
// Update virtual state from commands | |
this.updateVirtualFromCommands(commands); | |
} | |
/** | |
* Handle command sequence from master | |
*/ | |
private async handleMasterSequence(sequence: CommandSequence): Promise<void> { | |
console.log(`Received sequence "${sequence.name}" from master for robot ${this.id}`); | |
// Process sequence commands one by one | |
for (const command of sequence.commands) { | |
await this.handleMasterCommands([command]); | |
// Wait for command duration if specified | |
if (command.duration) { | |
await new Promise((resolve) => setTimeout(resolve, command.duration)); | |
} | |
} | |
} | |
/** | |
* Manual control - update joint value (when no master active) | |
*/ | |
async updateJointValue(jointName: string, value: number): Promise<void> { | |
if (!this.manualControlEnabled) { | |
console.warn(`Manual control disabled - master is active for robot ${this.id}`); | |
return; | |
} | |
const joint = this._joints.find((j) => j.name === jointName); | |
if (!joint) { | |
console.warn(`Joint ${jointName} not found in robot ${this.id}`); | |
return; | |
} | |
// Apply limits | |
const clampedValue = this.clampJointValue(joint, value); | |
// Update virtual state | |
joint.virtualValue = clampedValue; | |
joint.commandedValue = clampedValue; | |
joint.lastVirtualUpdate = new Date(); | |
joint.lastCommandUpdate = new Date(); | |
// Update URDF visualization | |
this.updateUrdfJointRotation(joint, clampedValue); | |
// Create command for slaves | |
const command: RobotCommand = { | |
timestamp: Date.now(), | |
joints: [{ name: jointName, value: clampedValue }] | |
}; | |
// Send to all connected slaves | |
const executePromises = this.connectedSlaves.map((slave) => | |
slave | |
.executeCommand(command) | |
.catch((error) => | |
console.error(`Slave ${slave.id} failed to execute manual command:`, error) | |
) | |
); | |
await Promise.allSettled(executePromises); | |
this._controlState.lastCommandSource = "manual"; | |
} | |
/** | |
* Manual control - update multiple joints | |
*/ | |
async updateJointValues(updates: { jointName: string; value: number }[]): Promise<void> { | |
if (!this.manualControlEnabled) { | |
console.warn(`Manual control disabled - master is active for robot ${this.id}`); | |
return; | |
} | |
// Update virtual states | |
const commandJoints: { name: string; value: number }[] = []; | |
for (const { jointName, value } of updates) { | |
const joint = this._joints.find((j) => j.name === jointName); | |
if (!joint) continue; | |
const clampedValue = this.clampJointValue(joint, value); | |
joint.virtualValue = clampedValue; | |
joint.commandedValue = clampedValue; | |
joint.lastVirtualUpdate = new Date(); | |
joint.lastCommandUpdate = new Date(); | |
// Update URDF visualization | |
this.updateUrdfJointRotation(joint, clampedValue); | |
commandJoints.push({ name: jointName, value: clampedValue }); | |
} | |
if (commandJoints.length === 0) return; | |
// Create batch command for slaves | |
const command: RobotCommand = { | |
timestamp: Date.now(), | |
joints: commandJoints | |
}; | |
// Send to all connected slaves | |
const executePromises = this.connectedSlaves.map((slave) => | |
slave | |
.executeCommand(command) | |
.catch((error) => | |
console.error(`Slave ${slave.id} failed to execute manual batch command:`, error) | |
) | |
); | |
await Promise.allSettled(executePromises); | |
this._controlState.lastCommandSource = "manual"; | |
} | |
// ============= STATE MANAGEMENT ============= | |
/** | |
* Update virtual state from master commands | |
*/ | |
private updateVirtualFromCommands(commands: RobotCommand[]): void { | |
for (const command of commands) { | |
for (const jointCommand of command.joints) { | |
const joint = this._joints.find((j) => j.name === jointCommand.name); | |
if (joint) { | |
joint.virtualValue = jointCommand.value; | |
joint.commandedValue = jointCommand.value; | |
joint.lastCommandUpdate = new Date(); | |
// Update URDF visualization | |
this.updateUrdfJointRotation(joint, jointCommand.value); | |
} | |
} | |
} | |
} | |
/** | |
* Update joint states from slave feedback | |
*/ | |
private updateFromSlave(slaveId: string, driverStates: DriverJointState[]): void { | |
for (const driverState of driverStates) { | |
const joint = this._joints.find((j) => j.name === driverState.name); | |
if (joint) { | |
// Apply calibration offset to raw real value | |
if (driverState.realValue !== undefined) { | |
joint.realValue = driverState.realValue + joint.calibrationOffset; | |
} else { | |
joint.realValue = undefined; | |
} | |
joint.lastRealUpdate = new Date(); | |
} | |
} | |
} | |
/** | |
* Update URDF joint rotation for 3D visualization | |
*/ | |
private updateUrdfJointRotation(joint: ManagedJointState, degrees: number): void { | |
const urdfJoint = joint.urdfJoint; | |
if (!urdfJoint) return; | |
if (urdfJoint.type === "revolute") { | |
const radians = (degrees * Math.PI) / 180; | |
const axis = urdfJoint.axis_xyz || [0, 0, 1]; | |
urdfJoint.rotation = [radians * axis[0], radians * axis[1], radians * axis[2]]; | |
} | |
} | |
/** | |
* Apply joint limits to a value | |
*/ | |
private clampJointValue(joint: ManagedJointState, value: number): number { | |
const limit = joint.urdfJoint.limit; | |
if (!limit) return value; | |
if (joint.urdfJoint.type === "revolute") { | |
if (limit.lower !== undefined && limit.upper !== undefined) { | |
const lowerDeg = (limit.lower * 180) / Math.PI; | |
const upperDeg = (limit.upper * 180) / Math.PI; | |
return Math.max(lowerDeg, Math.min(upperDeg, value)); | |
} | |
} else if (joint.urdfJoint.type === "continuous") { | |
if (limit.velocity !== undefined) { | |
return Math.max(-limit.velocity, Math.min(limit.velocity, value)); | |
} | |
} | |
return value; | |
} | |
// ============= MOVEMENT FUNCTIONS ============= | |
/** | |
* Move robot to rest position smoothly | |
*/ | |
async moveToRestPosition(durationMs: number = 3000): Promise<void> { | |
console.log(`Moving robot ${this.id} to rest position over ${durationMs}ms`); | |
const movements: Array<{ | |
joint: ManagedJointState; | |
startValue: number; | |
targetValue: number; | |
totalDelta: number; | |
}> = []; | |
for (const joint of this._joints) { | |
if (joint.isActive) { | |
const startValue = joint.virtualValue; | |
const targetValue = joint.restPosition; | |
const totalDelta = targetValue - startValue; | |
movements.push({ | |
joint, | |
startValue, | |
targetValue, | |
totalDelta | |
}); | |
} | |
} | |
if (movements.length === 0) { | |
console.log("No active joints to move"); | |
return; | |
} | |
const startTime = Date.now(); | |
const updateInterval = 50; | |
return new Promise((resolve) => { | |
const animationStep = () => { | |
const elapsed = Date.now() - startTime; | |
const progress = Math.min(elapsed / durationMs, 1.0); | |
const easedProgress = | |
progress < 0.5 | |
? 4 * progress * progress * progress | |
: 1 - Math.pow(-2 * progress + 2, 3) / 2; | |
const updates: Array<{ jointName: string; value: number }> = []; | |
for (const movement of movements) { | |
const currentValue = movement.startValue + movement.totalDelta * easedProgress; | |
updates.push({ | |
jointName: movement.joint.name, | |
value: currentValue | |
}); | |
} | |
this.updateJointValues(updates); | |
if (progress < 1.0) { | |
setTimeout(animationStep, updateInterval); | |
} else { | |
console.log(`Robot ${this.id} reached rest position`); | |
resolve(); | |
} | |
}; | |
animationStep(); | |
}); | |
} | |
/** | |
* Move a specific slave to rest position | |
*/ | |
private async moveSlaveToRestPosition(slave: SlaveDriver): Promise<void> { | |
const restCommand: RobotCommand = { | |
timestamp: Date.now(), | |
joints: this._joints.map((joint) => ({ | |
name: joint.name, | |
value: joint.restPosition | |
})) | |
}; | |
await slave.executeCommand(restCommand); | |
} | |
// ============= CALIBRATION ============= | |
/** | |
* Calibrate robot using first connected slave | |
*/ | |
async calibrateRobot(): Promise<void> { | |
const connectedSlaves = this.connectedSlaves; | |
if (connectedSlaves.length === 0) { | |
throw new Error("Cannot calibrate: no slaves connected"); | |
} | |
const primarySlave = connectedSlaves[0]; | |
console.log(`Calibrating robot ${this.id} using slave ${primarySlave.id}...`); | |
try { | |
const driverStates = await primarySlave.readJointStates(); | |
const offsets: { [jointName: string]: number } = {}; | |
for (const driverState of driverStates) { | |
const joint = this._joints.find((j) => j.name === driverState.name); | |
if (joint && driverState.realValue !== undefined) { | |
const offset = joint.restPosition - driverState.realValue; | |
offsets[joint.name] = offset; | |
joint.calibrationOffset = offset; | |
console.log( | |
`Joint ${joint.name}: rest=${joint.restPosition}° real=${driverState.realValue}° offset=${offset.toFixed(1)}°` | |
); | |
} | |
} | |
this._calibrationState = { | |
isCalibrated: true, | |
calibrationTime: new Date(), | |
offsets | |
}; | |
console.log(`Robot ${this.id} calibrated successfully`); | |
} catch (error) { | |
console.error(`Calibration failed for robot ${this.id}:`, error); | |
throw error; | |
} | |
} | |
/** | |
* Clear calibration | |
*/ | |
clearCalibration(): void { | |
this._calibrationState = { | |
isCalibrated: false, | |
offsets: {} | |
}; | |
this._joints.forEach((joint) => { | |
joint.calibrationOffset = 0; | |
}); | |
console.log(`Cleared calibration for robot ${this.id}`); | |
} | |
// ============= LIFECYCLE ============= | |
/** | |
* Get joint by name | |
*/ | |
getJoint(name: string): ManagedJointState | undefined { | |
return this._joints.find((j) => j.name === name); | |
} | |
/** | |
* Clean up resources | |
*/ | |
async destroy(): Promise<void> { | |
// Clean up subscriptions | |
this.unsubscribeCallbacks.forEach((unsub) => unsub()); | |
this.unsubscribeCallbacks = []; | |
// Remove master | |
await this.removeMaster(); | |
// Remove all slaves | |
const slaveIds = this._slaves.map((s) => s.id); | |
for (const slaveId of slaveIds) { | |
await this.removeSlave(slaveId); | |
} | |
console.log(`Robot ${this.id} destroyed`); | |
} | |
} | |