Spaces:
Running
Running
<script lang="ts"> | |
import type { Robot } from "../Robot.svelte.js"; | |
import { robotManager } from "../RobotManager.svelte.js"; | |
import { settings } from "$lib/runes/settings.svelte"; | |
import USBCalibrationPanel from "../calibration/USBCalibrationPanel.svelte"; | |
interface Props { | |
robot: Robot; | |
workspaceId: string; | |
} | |
let { robot, workspaceId }: Props = $props(); | |
const hasConsumer = $derived(robot.hasConsumer); | |
const outputDriverCount = $derived(robot.outputDriverCount); | |
const consumer = $derived(robot.consumer); | |
const producers = $derived(robot.producers); | |
// Connection configs - using transport server for communication | |
let remoteRobotId = $state(robot.id); | |
let connecting = $state(false); | |
let error = $state<string | null>(null); | |
// USB connection flow state | |
let showUSBCalibration = $state(false); | |
let pendingUSBConnection: "consumer" | "producer" | null = $state(null); | |
// Room management state | |
const rooms = $derived(robotManager.rooms); | |
const roomsLoading = $derived(robotManager.roomsLoading); | |
let selectedRoomId = $state(""); | |
let newRoomId = $state(""); | |
let showRoomManagement = $state(true); // Show rooms by default | |
// Auto-load rooms when component loads | |
$effect(() => { | |
if (rooms.length === 0 && !roomsLoading && workspaceId) { | |
refreshRooms(); | |
} | |
}); | |
// Find USB driver for calibration (if any) | |
function getUSBDriver(): any { | |
// Check consumer first | |
if (robot.consumer && 'calibrationState' in robot.consumer) { | |
return robot.consumer; | |
} | |
// Then check producers | |
return robot.producers.find(p => 'calibrationState' in p) || null; | |
} | |
async function connectUSBConsumer() { | |
try { | |
connecting = true; | |
error = null; | |
// USB drivers handle their own calibration requirements | |
await robot.setConsumer({ | |
type: "usb", | |
baudRate: 1000000 | |
}); | |
} catch (err) { | |
console.error("Failed to connect USB consumer:", err); | |
// Check if it's a calibration error | |
if (err instanceof Error && err.message.includes('calibration')) { | |
pendingUSBConnection = "consumer"; | |
showUSBCalibration = true; | |
return; | |
} | |
error = err instanceof Error ? err.message : "Unknown error"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function connectRemoteConsumer() { | |
try { | |
connecting = true; | |
error = null; | |
await robot.setConsumer({ | |
type: "remote", | |
url: settings.transportServerUrl, | |
robotId: remoteRobotId, | |
workspaceId: workspaceId | |
}); | |
} catch (err) { | |
console.error("Failed to connect remote consumer:", err); | |
error = err instanceof Error ? err.message : "Unknown error"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function connectUSBProducer() { | |
try { | |
connecting = true; | |
error = null; | |
// USB drivers handle their own calibration requirements | |
await robot.addProducer({ | |
type: "usb", | |
baudRate: 1000000 | |
}); | |
} catch (err) { | |
console.error("Failed to connect USB producer:", err); | |
// Check if it's a calibration error | |
if (err instanceof Error && err.message.includes('calibration')) { | |
pendingUSBConnection = "producer"; | |
showUSBCalibration = true; | |
return; | |
} | |
error = err instanceof Error ? err.message : "Unknown error"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function connectRemoteProducer() { | |
try { | |
connecting = true; | |
error = null; | |
await robot.addProducer({ | |
type: "remote", | |
url: settings.transportServerUrl, | |
robotId: remoteRobotId, | |
workspaceId: workspaceId | |
}); | |
} catch (err) { | |
console.error("Failed to connect remote producer:", err); | |
error = err instanceof Error ? err.message : "Unknown error"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function disconnectConsumer() { | |
try { | |
connecting = true; | |
error = null; | |
await robot.removeConsumer(); | |
} catch (err) { | |
console.error("Failed to disconnect consumer:", err); | |
error = err instanceof Error ? err.message : "Unknown error"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function disconnectProducer(producerId: string) { | |
try { | |
connecting = true; | |
error = null; | |
await robot.removeProducer(producerId); | |
} catch (err) { | |
console.error("Failed to disconnect producer:", err); | |
error = err instanceof Error ? err.message : "Unknown error"; | |
} finally { | |
connecting = false; | |
} | |
} | |
// Room management functions | |
async function refreshRooms() { | |
try { | |
await robotManager.refreshRooms(workspaceId); | |
} catch (err) { | |
console.error("Failed to refresh rooms:", err); | |
error = err instanceof Error ? err.message : "Failed to refresh rooms"; | |
} | |
} | |
async function createRoom() { | |
try { | |
connecting = true; | |
error = null; | |
const result = await robotManager.createRoboticsRoom(workspaceId, newRoomId || undefined); | |
if (result.success) { | |
newRoomId = ""; | |
await refreshRooms(); | |
} else { | |
error = result.error || "Failed to create room"; | |
} | |
} catch (err) { | |
console.error("Failed to create room:", err); | |
error = err instanceof Error ? err.message : "Failed to create room"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function joinRoomAsConsumer() { | |
if (!selectedRoomId) { | |
error = "Please select a room"; | |
return; | |
} | |
try { | |
connecting = true; | |
error = null; | |
await robotManager.connectConsumerToRoom(workspaceId, robot.id, selectedRoomId); | |
} catch (err) { | |
console.error("Failed to join room as consumer:", err); | |
error = err instanceof Error ? err.message : "Failed to join room as consumer"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function joinRoomAsProducer() { | |
if (!selectedRoomId) { | |
error = "Please select a room"; | |
return; | |
} | |
try { | |
connecting = true; | |
error = null; | |
await robotManager.connectProducerToRoom(workspaceId, robot.id, selectedRoomId); | |
} catch (err) { | |
console.error("Failed to join room as producer:", err); | |
error = err instanceof Error ? err.message : "Failed to join room as producer"; | |
} finally { | |
connecting = false; | |
} | |
} | |
async function createRoomAndJoinAsProducer() { | |
try { | |
connecting = true; | |
error = null; | |
const result = await robotManager.connectProducerAsProducer( | |
workspaceId, | |
robot.id, | |
newRoomId || undefined | |
); | |
if (result.success) { | |
newRoomId = ""; | |
await refreshRooms(); | |
} else { | |
error = result.error || "Failed to create room and join as producer"; | |
} | |
} catch (err) { | |
console.error("Failed to create room and join as producer:", err); | |
error = err instanceof Error ? err.message : "Failed to create room and join as producer"; | |
} finally { | |
connecting = false; | |
} | |
} | |
// Handle calibration completion | |
async function onCalibrationComplete() { | |
showUSBCalibration = false; | |
if (pendingUSBConnection === "consumer") { | |
await connectUSBConsumer(); | |
} else if (pendingUSBConnection === "producer") { | |
await connectUSBProducer(); | |
} | |
pendingUSBConnection = null; | |
} | |
function onCalibrationCancel() { | |
showUSBCalibration = false; | |
pendingUSBConnection = null; | |
connecting = false; | |
} | |
</script> | |
<div class="space-y-4"> | |
<!-- Connection panel --> | |
<div class="space-y-4 rounded-lg border border-slate-600 bg-slate-800 p-4"> | |
<h3 class="text-lg font-semibold text-slate-100">Connections - {robot.id}</h3> | |
<!-- Error display --> | |
{#if error} | |
<div class="rounded border border-red-500/30 bg-red-900/20 p-2 text-sm text-red-400"> | |
{error} | |
</div> | |
{/if} | |
<!-- Room Management Section --> | |
<div class="space-y-2"> | |
<div class="flex items-center justify-between"> | |
<h4 class="text-sm font-medium text-slate-300">Room Management</h4> | |
<button | |
onclick={() => (showRoomManagement = !showRoomManagement)} | |
class="text-xs text-blue-400 hover:text-blue-300" | |
> | |
{showRoomManagement ? "Hide" : "Show"} Rooms | |
</button> | |
</div> | |
{#if showRoomManagement} | |
<div class="space-y-3 rounded border border-slate-600 bg-slate-700/30 p-3"> | |
<!-- Refresh Rooms --> | |
<div class="flex items-center gap-2"> | |
<button | |
onclick={refreshRooms} | |
disabled={roomsLoading || connecting} | |
class="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{#if roomsLoading} | |
<span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span> | |
Loading... | |
{:else} | |
<span class="icon-[mdi--refresh] mr-1 size-3"></span> | |
Refresh Rooms | |
{/if} | |
</button> | |
<span class="text-xs text-slate-400"> | |
{rooms.length} room{rooms.length !== 1 ? "s" : ""} available | |
</span> | |
</div> | |
<!-- Available Rooms --> | |
<div class="space-y-2"> | |
<span class="text-xs text-slate-400">Available Rooms:</span> | |
<div class="max-h-48 space-y-2 overflow-y-auto"> | |
<!-- Create New Room Option --> | |
<div class="rounded border-2 border-dashed border-green-500/50 bg-green-500/5 p-2"> | |
<div class="space-y-2"> | |
<div class="flex items-center gap-2"> | |
<span class="text-sm font-medium text-green-300">Create New Room</span> | |
</div> | |
<p class="text-xs text-green-400/70">Create a room for collaboration</p> | |
<input | |
bind:value={newRoomId} | |
placeholder="Room ID (optional)" | |
disabled={connecting} | |
class="w-full rounded border border-slate-600 bg-slate-700 px-2 py-1 text-xs text-slate-100 disabled:opacity-50" | |
/> | |
<div class="flex gap-1"> | |
<button | |
onclick={createRoom} | |
disabled={connecting} | |
class="flex-1 rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{#if connecting} | |
<span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span> | |
Creating... | |
{:else} | |
Create | |
{/if} | |
</button> | |
<button | |
onclick={createRoomAndJoinAsProducer} | |
disabled={connecting} | |
class="flex-1 rounded bg-green-600 px-2 py-1 text-xs text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{#if connecting} | |
<span class="icon-[mdi--loading] mr-1 size-3 animate-spin"></span> | |
Creating... | |
{:else} | |
Create & Join | |
{/if} | |
</button> | |
</div> | |
</div> | |
</div> | |
<!-- Existing Rooms --> | |
{#if rooms.length === 0} | |
<div class="py-2 text-center text-xs text-slate-500"> | |
{roomsLoading ? "Loading..." : "No existing rooms available"} | |
</div> | |
{:else} | |
{#each rooms as room} | |
<div class="rounded border border-slate-600 bg-slate-700/30 p-2"> | |
<div class="mb-2"> | |
<p class="truncate text-sm font-medium text-slate-200">{room.id}</p> | |
<p class="text-xs text-slate-400"> | |
{room.participants?.total || 0} participants | |
</p> | |
</div> | |
<div class="flex gap-1"> | |
<button | |
onclick={() => { | |
selectedRoomId = room.id; | |
joinRoomAsConsumer(); | |
}} | |
disabled={connecting} | |
class="flex-1 rounded bg-purple-600 px-2 py-1 text-xs text-white hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
Join as Consumer | |
</button> | |
<button | |
onclick={() => { | |
selectedRoomId = room.id; | |
joinRoomAsProducer(); | |
}} | |
disabled={connecting} | |
class="flex-1 rounded bg-orange-600 px-2 py-1 text-xs text-white hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
Join as Producer | |
</button> | |
</div> | |
</div> | |
{/each} | |
{/if} | |
</div> | |
</div> | |
</div> | |
{/if} | |
</div> | |
<!-- Consumer Section (Input) - SINGLE --> | |
<div class="space-y-2"> | |
<h4 class="text-sm font-medium text-slate-300">Consumer (Receive Commands) - Single</h4> | |
{#if hasConsumer} | |
<div | |
class="flex items-center justify-between rounded border border-green-500/30 bg-green-900/20 p-2" | |
> | |
<div> | |
<span class="text-sm text-green-300">{consumer?.name || "Consumer Active"}</span> | |
<span class="ml-2 text-xs text-slate-500"> | |
{consumer?.status.isConnected ? "🟢 Connected" : "🔴 Disconnected"} | |
</span> | |
</div> | |
<button | |
onclick={disconnectConsumer} | |
disabled={connecting} | |
class="rounded bg-red-600 px-3 py-1 text-xs text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{connecting ? "Disconnecting..." : "Disconnect"} | |
</button> | |
</div> | |
{:else} | |
<div class="space-y-2"> | |
<button | |
onclick={connectUSBConsumer} | |
disabled={connecting} | |
class="w-full rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{connecting ? "Connecting..." : "Connect USB Consumer"} | |
</button> | |
<div class="space-y-2"> | |
<div class="flex gap-2"> | |
<input | |
bind:value={settings.transportServerUrl} | |
placeholder="Transport server URL (e.g. http://localhost:8000)" | |
disabled={connecting} | |
class="flex-1 rounded border border-slate-600 bg-slate-700 px-3 py-2 text-sm text-slate-100 disabled:opacity-50" | |
/> | |
<button | |
onclick={connectRemoteConsumer} | |
disabled={connecting} | |
class="rounded bg-purple-600 px-3 py-2 text-sm whitespace-nowrap text-white hover:bg-purple-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{connecting ? "Connecting..." : "Remote Consumer"} | |
</button> | |
</div> | |
<div class="text-xs text-slate-500"> | |
Remote Consumer: Receive commands from transport server | |
</div> | |
</div> | |
</div> | |
{/if} | |
</div> | |
<!-- Producers Section (Output) - MULTIPLE --> | |
<div class="space-y-2"> | |
<h4 class="text-sm font-medium text-slate-300"> | |
Producers (Send Commands) - {outputDriverCount} connected | |
</h4> | |
<div class="space-y-2"> | |
<button | |
onclick={connectUSBProducer} | |
disabled={connecting} | |
class="w-full rounded bg-green-600 px-3 py-2 text-sm text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{connecting ? "Connecting..." : "Add USB Producer"} | |
</button> | |
<button | |
onclick={connectRemoteProducer} | |
disabled={connecting} | |
class="w-full rounded bg-orange-600 px-3 py-2 text-sm text-white hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{connecting ? "Connecting..." : "Add Remote Producer"} | |
</button> | |
<div class="text-xs text-slate-500"> | |
Remote Producer: Send commands to transport server. Uses Robot ID: {remoteRobotId} | |
</div> | |
</div> | |
<!-- Connected Producers List --> | |
{#each producers as producer} | |
<div | |
class="flex items-center justify-between rounded border border-slate-600 bg-slate-700/50 p-2" | |
> | |
<div> | |
<span class="text-sm text-slate-300">{producer.name}</span> | |
<span class="ml-2 text-xs text-slate-500"> | |
{producer.status.isConnected ? "🟢 Connected" : "🔴 Disconnected"} | |
</span> | |
</div> | |
<button | |
onclick={() => disconnectProducer(producer.id)} | |
disabled={connecting} | |
class="rounded bg-red-600 px-2 py-1 text-xs text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50" | |
> | |
{connecting ? "Removing..." : "Remove"} | |
</button> | |
</div> | |
{/each} | |
</div> | |
<!-- Robot ID Config --> | |
<div class="border-t border-slate-600 pt-2"> | |
<span class="text-xs text-slate-400">Robot ID for Remote Connections:</span> | |
<input | |
bind:value={remoteRobotId} | |
disabled={connecting} | |
class="mt-1 w-full rounded border border-slate-600 bg-slate-700 px-3 py-1 text-sm text-slate-100 disabled:opacity-50" | |
/> | |
</div> | |
</div> | |
<!-- USB Calibration Modal - Only shown when connecting USB drivers --> | |
{#if showUSBCalibration} | |
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
<div class="m-4 w-full max-w-2xl space-y-4 rounded-lg bg-slate-800 p-6"> | |
<div class="flex items-center justify-between"> | |
<h2 class="text-lg font-semibold text-white"> | |
USB Calibration Required | |
{#if pendingUSBConnection} | |
<span class="ml-2 text-sm text-slate-400"> | |
(for {pendingUSBConnection === "consumer" ? "Consumer" : "Producer"}) | |
</span> | |
{/if} | |
</h2> | |
<button onclick={onCalibrationCancel} class="text-gray-400 hover:text-white"> ✕ </button> | |
</div> | |
<div class="mb-4 text-sm text-slate-300"> | |
Before connecting USB drivers, the robot needs to be calibrated to map its physical range | |
to software values. | |
</div> | |
{#if getUSBDriver()} | |
<USBCalibrationPanel | |
calibrationManager={getUSBDriver()} | |
connectionType={pendingUSBConnection || "consumer"} | |
{onCalibrationComplete} | |
onCancel={onCalibrationCancel} | |
/> | |
{:else} | |
<div class="text-center text-slate-400"> | |
No USB driver available for calibration | |
</div> | |
{/if} | |
</div> | |
</div> | |
{/if} | |
</div> | |