Spaces:
Running
Running
<script lang="ts"> | |
import { onMount } from 'svelte'; | |
import { robotics } from 'lerobot-arena-client'; | |
import type { robotics as roboticsTypes } from 'lerobot-arena-client'; | |
// Get data from load function | |
let { data } = $props(); | |
let workspaceId = data.workspaceId; | |
let roomIdFromUrl = data.roomId; | |
// State | |
let consumer: robotics.RoboticsConsumer; | |
let connected = $state<boolean>(false); | |
let connecting = $state<boolean>(false); | |
let roomId = $state<string>(roomIdFromUrl || ''); | |
let participantId = $state<string>(''); | |
let error = $state<string>(''); | |
// Current robot state | |
let currentJoints = $state<Record<string, number>>({}); | |
let lastUpdate = $state<Date | null>(null); | |
// Real-time monitoring | |
let updateCount = $state<number>(0); | |
let stateSyncCount = $state<number>(0); | |
let errorCount = $state<number>(0); | |
// History tracking | |
let jointHistory = $state< | |
Array<{ timestamp: string; joints: roboticsTypes.JointData[]; source?: string }> | |
>([]); | |
let stateHistory = $state<Array<{ timestamp: string; state: Record<string, number> }>>([]); | |
let errorHistory = $state<Array<{ timestamp: string; message: string }>>([]); | |
// Visualization data for joint trend (last 20 updates) | |
let jointTrends = $state<Record<string, number[]>>({}); | |
// Debug info | |
let debugInfo = $state<{ | |
connectionAttempts: number; | |
messagesReceived: number; | |
lastMessageType: string; | |
wsConnected: boolean; | |
currentRoom: string; | |
workspaceId: string; | |
}>({ | |
connectionAttempts: 0, | |
messagesReceived: 0, | |
lastMessageType: '', | |
wsConnected: false, | |
currentRoom: '', | |
workspaceId: workspaceId | |
}); | |
function updateUrlWithRoom(roomId: string) { | |
const url = new URL(window.location.href); | |
url.searchParams.set('room', roomId); | |
window.history.replaceState({}, '', url.toString()); | |
} | |
async function connectConsumer() { | |
if (!roomId.trim() || !participantId.trim()) { | |
error = 'Please enter both Room ID and Participant ID'; | |
return; | |
} | |
debugInfo.connectionAttempts++; | |
try { | |
connecting = true; | |
error = ''; | |
consumer = new robotics.RoboticsConsumer('http://localhost:8000'); | |
// Set up event handlers | |
consumer.onConnected(() => { | |
connected = true; | |
connecting = false; | |
debugInfo.wsConnected = true; | |
debugInfo.currentRoom = roomId; | |
loadInitialState(); | |
updateUrlWithRoom(roomId); | |
}); | |
consumer.onDisconnected(() => { | |
connected = false; | |
debugInfo.wsConnected = false; | |
}); | |
consumer.onError((errorMsg) => { | |
error = errorMsg; | |
errorCount++; | |
debugInfo.messagesReceived++; | |
debugInfo.lastMessageType = 'ERROR'; | |
errorHistory = [ | |
{ | |
timestamp: new Date().toLocaleTimeString(), | |
message: errorMsg | |
}, | |
...errorHistory | |
].slice(0, 10); // Keep last 10 errors only | |
}); | |
// Joint update callback | |
consumer.onJointUpdate((joints) => { | |
updateCount++; | |
lastUpdate = new Date(); | |
debugInfo.messagesReceived++; | |
debugInfo.lastMessageType = 'JOINT_UPDATE'; | |
// Update current state | |
joints.forEach((joint) => { | |
currentJoints[joint.name] = joint.value; | |
// Update trends | |
if (!jointTrends[joint.name]) { | |
jointTrends[joint.name] = []; | |
} | |
jointTrends[joint.name] = [...jointTrends[joint.name], joint.value].slice(-10); // Keep last 10 only | |
}); | |
// Add to history | |
jointHistory = [ | |
{ | |
timestamp: new Date().toLocaleTimeString(), | |
joints, | |
source: 'producer' | |
}, | |
...jointHistory | |
].slice(0, 20); // Keep last 20 updates only | |
}); | |
// State sync callback | |
consumer.onStateSync((state) => { | |
stateSyncCount++; | |
lastUpdate = new Date(); | |
debugInfo.messagesReceived++; | |
debugInfo.lastMessageType = 'STATE_SYNC'; | |
// Update current state | |
currentJoints = { ...state }; | |
// Update trends for all joints | |
Object.entries(state).forEach(([name, value]) => { | |
if (!jointTrends[name]) { | |
jointTrends[name] = []; | |
} | |
jointTrends[name] = [...jointTrends[name], value].slice(-10); // Keep last 10 only | |
}); | |
// Add to history | |
stateHistory = [ | |
{ | |
timestamp: new Date().toLocaleTimeString(), | |
state | |
}, | |
...stateHistory | |
].slice(0, 10); // Keep last 10 state syncs only | |
}); | |
const success = await consumer.connect(workspaceId, roomId, participantId); | |
if (!success) { | |
error = 'Failed to connect. Room might not exist.'; | |
connecting = false; | |
} | |
} catch (err) { | |
error = `Connection failed: ${err}`; | |
connecting = false; | |
} | |
} | |
async function loadInitialState() { | |
if (!consumer || !connected) return; | |
try { | |
const state = await consumer.getStateSyncAsync(); | |
if (Object.keys(state).length > 0) { | |
currentJoints = state; | |
// Initialize trends | |
Object.entries(state).forEach(([name, value]) => { | |
jointTrends[name] = [value]; | |
}); | |
} | |
} catch (err) { | |
console.error('Failed to load initial state:', err); | |
} | |
} | |
async function exitSession() { | |
if (consumer && connected) { | |
await consumer.disconnect(); | |
} | |
connected = false; | |
debugInfo.wsConnected = false; | |
debugInfo.currentRoom = ''; | |
} | |
function clearHistory() { | |
jointHistory = []; | |
stateHistory = []; | |
errorHistory = []; | |
updateCount = 0; | |
stateSyncCount = 0; | |
errorCount = 0; | |
debugInfo.messagesReceived = 0; | |
} | |
// Update roomId when URL parameter changes | |
$effect(() => { | |
if (roomIdFromUrl) { | |
roomId = roomIdFromUrl; | |
} | |
}); | |
// Update debugInfo when workspaceId changes | |
$effect(() => { | |
debugInfo.workspaceId = workspaceId; | |
}); | |
onMount(() => { | |
participantId = `consumer_${Date.now()}`; | |
return () => { | |
exitSession(); | |
}; | |
}); | |
</script> | |
<svelte:head> | |
<title>Robotics Consumer{roomId ? ` - Room ${roomId}` : ''} - Workspace {workspaceId} - LeRobot Arena</title> | |
</svelte:head> | |
<div class="mx-auto max-w-6xl space-y-6"> | |
<!-- Header --> | |
<div class="flex items-center justify-between"> | |
<div> | |
<h1 class="font-mono text-2xl font-bold text-gray-900">🤖 Robotics Consumer</h1> | |
<p class="mt-1 font-mono text-sm text-gray-600"> | |
Workspace: <span class="font-bold text-blue-600">{workspaceId}</span> | |
{#if roomId} | |
| Room: <span class="font-bold text-blue-600">{roomId}</span> | |
{:else} | |
| Monitor robot arm state in real-time | |
{/if} | |
</p> | |
</div> | |
<div class="flex items-center space-x-4"> | |
<div class="flex items-center space-x-2"> | |
{#if connected} | |
<div class="h-3 w-3 rounded-full bg-green-500"></div> | |
<span class="font-mono text-sm font-medium text-green-700">Connected</span> | |
{:else if connecting} | |
<div class="h-3 w-3 animate-pulse rounded-full bg-yellow-500"></div> | |
<span class="font-mono text-sm font-medium text-yellow-700">Connecting...</span> | |
{:else} | |
<div class="h-3 w-3 rounded-full bg-red-500"></div> | |
<span class="font-mono text-sm font-medium text-red-700">Disconnected</span> | |
{/if} | |
</div> | |
<a | |
href="/{workspaceId}/robotics" | |
class="rounded border bg-gray-100 px-3 py-2 font-mono text-sm hover:bg-gray-200" | |
> | |
← Back to Robotics | |
</a> | |
</div> | |
</div> | |
<!-- Debug Info --> | |
<div class="rounded border bg-gray-900 p-4 font-mono text-sm text-green-400"> | |
<div class="mb-2 font-bold">ROBOTICS CONSUMER DEBUG - WORKSPACE {debugInfo.workspaceId}{roomId ? ` - ROOM ${roomId}` : ''}</div> | |
<div class="grid grid-cols-3 gap-4 md:grid-cols-5"> | |
<div>Attempts: {debugInfo.connectionAttempts}</div> | |
<div>Messages: {debugInfo.messagesReceived}</div> | |
<div>Last: {debugInfo.lastMessageType || 'None'}</div> | |
<div>WS: {debugInfo.wsConnected ? 'ON' : 'OFF'}</div> | |
<div>Room: {debugInfo.currentRoom || 'None'}</div> | |
</div> | |
<div class="mt-2 grid grid-cols-3 gap-4"> | |
<div>Updates: {updateCount}</div> | |
<div>State Syncs: {stateSyncCount}</div> | |
<div>Errors: {errorCount}</div> | |
</div> | |
<div class="mt-2">Last Update: {lastUpdate ? lastUpdate.toLocaleTimeString() : 'Never'}</div> | |
{#if error} | |
<div class="mt-2 text-red-400">Error: {error}</div> | |
{/if} | |
</div> | |
{#if !connected} | |
<!-- Connection Section --> | |
<div class="rounded border p-6"> | |
<h2 class="mb-4 font-mono text-lg font-semibold">Connect to Robotics Room</h2> | |
<div class="grid grid-cols-1 gap-6 md:grid-cols-2"> | |
<div> | |
<label for="roomId" class="mb-1 block font-mono text-sm font-medium text-gray-700"> | |
Room ID | |
</label> | |
<input | |
id="roomId" | |
type="text" | |
bind:value={roomId} | |
placeholder="Enter room ID to monitor" | |
class="w-full rounded border border-gray-300 px-3 py-2 font-mono focus:border-blue-500 focus:ring-blue-500" | |
/> | |
</div> | |
<div> | |
<label for="participantId" class="mb-1 block font-mono text-sm font-medium text-gray-700"> | |
Participant ID | |
</label> | |
<input | |
id="participantId" | |
type="text" | |
bind:value={participantId} | |
placeholder="Your participant ID" | |
class="w-full rounded border border-gray-300 px-3 py-2 font-mono focus:border-blue-500 focus:ring-blue-500" | |
/> | |
</div> | |
</div> | |
<div class="mt-4"> | |
<button | |
onclick={connectConsumer} | |
disabled={connecting || !roomId.trim() || !participantId.trim()} | |
class={[ | |
'rounded border px-4 py-2 font-mono', | |
connecting || !roomId.trim() || !participantId.trim() | |
? 'bg-gray-200 text-gray-500' | |
: 'bg-blue-600 text-white hover:bg-blue-700' | |
]} | |
> | |
{connecting ? 'Connecting...' : 'Join as Observer'} | |
</button> | |
</div> | |
{#if error} | |
<div class="mt-4 rounded border border-red-200 bg-red-50 p-4"> | |
<p class="font-mono text-sm text-red-700">{error}</p> | |
</div> | |
{/if} | |
</div> | |
{:else} | |
<!-- Monitoring Interface --> | |
<div class="space-y-6"> | |
<!-- Status Overview --> | |
<div class="grid grid-cols-2 gap-4 md:grid-cols-4"> | |
<div class="rounded border p-4 text-center"> | |
<div class="font-mono text-2xl font-bold text-blue-600">{updateCount}</div> | |
<div class="font-mono text-sm text-gray-500">Joint Updates</div> | |
</div> | |
<div class="rounded border p-4 text-center"> | |
<div class="font-mono text-2xl font-bold text-purple-600">{stateSyncCount}</div> | |
<div class="font-mono text-sm text-gray-500">State Syncs</div> | |
</div> | |
<div class="rounded border p-4 text-center"> | |
<div class="font-mono text-2xl font-bold text-red-600">{errorCount}</div> | |
<div class="font-mono text-sm text-gray-500">Errors</div> | |
</div> | |
<div class="rounded border p-4 text-center"> | |
<div class="font-mono text-2xl font-bold text-green-600"> | |
{lastUpdate ? lastUpdate.toLocaleTimeString() : 'N/A'} | |
</div> | |
<div class="font-mono text-sm text-gray-500">Last Update</div> | |
</div> | |
</div> | |
<!-- Robot State Display --> | |
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3"> | |
<!-- Robot Visualization --> | |
<div class="rounded border p-4 lg:col-span-2"> | |
<h2 class="mb-3 font-mono text-lg font-semibold">Robot Arm Visualization</h2> | |
<div class="aspect-video rounded border bg-gray-50 p-4"> | |
<div class="flex h-full items-center justify-center"> | |
<div class="text-center"> | |
<div class="mb-4 text-6xl">🦾</div> | |
<p class="font-mono text-gray-600">3D Robot Visualization</p> | |
<p class="font-mono text-xs text-gray-500">Live joint positions from producer</p> | |
</div> | |
</div> | |
</div> | |
<!-- Current Joint Values --> | |
{#if Object.keys(currentJoints).length > 0} | |
<div class="mt-4 grid grid-cols-2 gap-4 md:grid-cols-3"> | |
{#each Object.entries(currentJoints) as [name, value]} | |
<div class="text-center"> | |
<div class="font-mono text-sm text-gray-500 capitalize"> | |
{name.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase())} | |
</div> | |
<div class="font-mono text-lg font-bold">{value.toFixed(1)}°</div> | |
</div> | |
{/each} | |
</div> | |
{/if} | |
</div> | |
<!-- Session Info & Controls --> | |
<div class="space-y-6"> | |
<!-- Session Info --> | |
<div class="rounded border p-4"> | |
<h2 class="mb-3 font-mono text-lg font-semibold">Session Info</h2> | |
<div class="grid grid-cols-1 gap-2 font-mono text-sm"> | |
<div><span class="text-gray-500">Workspace:</span> {workspaceId}</div> | |
<div><span class="text-gray-500">Room:</span> {roomId}</div> | |
<div><span class="text-gray-500">ID:</span> {participantId}</div> | |
<div><span class="text-gray-500">Role:</span> Consumer</div> | |
<div><span class="text-gray-500">Active Joints:</span> {Object.keys(currentJoints).length}</div> | |
</div> | |
<div class="mt-4 flex space-x-3"> | |
<button | |
onclick={clearHistory} | |
class="rounded border bg-gray-100 px-3 py-2 font-mono text-sm hover:bg-gray-200" | |
> | |
Clear History | |
</button> | |
<button | |
onclick={exitSession} | |
class="rounded border bg-gray-100 px-3 py-2 font-mono text-sm hover:bg-gray-200" | |
> | |
🚪 Exit Session | |
</button> | |
</div> | |
</div> | |
<!-- Joint Trends --> | |
{#if Object.keys(jointTrends).length > 0} | |
<div class="rounded border p-4"> | |
<h2 class="mb-3 font-mono text-lg font-semibold">Joint Trends</h2> | |
<div class="max-h-48 space-y-2 overflow-y-auto"> | |
{#each Object.entries(jointTrends) as [name, values]} | |
<div class="rounded bg-gray-50 p-2"> | |
<div class="flex items-center justify-between"> | |
<span class="font-mono text-xs text-gray-600 capitalize"> | |
{name.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase())} | |
</span> | |
<span class="font-mono text-xs font-bold"> | |
{values[values.length - 1]?.toFixed(1) || '0.0'}° | |
</span> | |
</div> | |
<div class="mt-1 flex h-4 items-end space-x-1"> | |
{#each values as value, i} | |
<div | |
class="w-2 bg-blue-300" | |
style="height: {Math.abs(value) / 180 * 100}%" | |
title="{value.toFixed(1)}°" | |
></div> | |
{/each} | |
</div> | |
</div> | |
{/each} | |
</div> | |
</div> | |
{/if} | |
</div> | |
</div> | |
<!-- Real-time Updates --> | |
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2"> | |
<!-- Joint Updates History --> | |
<div class="rounded border p-4"> | |
<h2 class="mb-3 font-mono text-lg font-semibold">Recent Joint Updates</h2> | |
<div class="max-h-64 space-y-2 overflow-y-auto"> | |
{#each jointHistory.slice(0, 10) as update} | |
<div class="rounded border-l-4 border-blue-200 bg-gray-50 p-2"> | |
<div class="flex items-center justify-between"> | |
<span class="font-mono text-xs text-gray-500">{update.timestamp}</span> | |
<span class="font-mono text-xs text-blue-600">JOINT_UPDATE</span> | |
</div> | |
<div class="mt-1 text-sm"> | |
<span class="font-mono text-gray-700"> | |
{update.joints.length} joint(s): | |
{update.joints.map(j => `${j.name}=${j.value.toFixed(1)}°`).join(', ')} | |
</span> | |
</div> | |
</div> | |
{:else} | |
<p class="py-4 text-center font-mono text-sm text-gray-500">No joint updates received yet</p> | |
{/each} | |
</div> | |
</div> | |
<!-- State Sync History --> | |
<div class="rounded border p-4"> | |
<h2 class="mb-3 font-mono text-lg font-semibold">State Sync History</h2> | |
<div class="max-h-64 space-y-2 overflow-y-auto"> | |
{#each stateHistory as state} | |
<div class="rounded border-l-4 border-purple-200 bg-gray-50 p-2"> | |
<div class="flex items-center justify-between"> | |
<span class="font-mono text-xs text-gray-500">{state.timestamp}</span> | |
<span class="font-mono text-xs text-purple-600">STATE_SYNC</span> | |
</div> | |
<div class="mt-1 text-sm"> | |
<div class="font-mono text-gray-700"> | |
{Object.keys(state.state).length} joints synchronized | |
</div> | |
</div> | |
</div> | |
{:else} | |
<p class="py-4 text-center font-mono text-sm text-gray-500"> | |
No state syncs received yet | |
</p> | |
{/each} | |
</div> | |
</div> | |
</div> | |
<!-- Error History --> | |
{#if errorHistory.length > 0} | |
<div class="rounded border p-4"> | |
<h2 class="mb-3 font-mono text-lg font-semibold">Recent Errors</h2> | |
<div class="max-h-32 space-y-2 overflow-y-auto"> | |
{#each errorHistory as error} | |
<div class="rounded border-l-4 border-red-200 bg-red-50 p-2"> | |
<div class="flex items-center justify-between"> | |
<span class="font-mono text-xs text-gray-500">{error.timestamp}</span> | |
<span class="font-mono text-xs text-red-600">ERROR</span> | |
</div> | |
<div class="mt-1 font-mono text-sm text-red-700"> | |
{error.message} | |
</div> | |
</div> | |
{/each} | |
</div> | |
</div> | |
{/if} | |
</div> | |
{/if} | |
</div> |