nova-sim / frontend /index.html
Georg
Prepare HF Space deployment
6eb42dc
<!DOCTYPE html>
<html>
<head>
<title>Nova Sim - Wandelbots Robot Simulator</title>
<style>
/* Wandelbots Corporate Design Colors:
Primary Dark: #01040f (Blue Charcoal)
Light/Secondary: #bcbeec (Spindle - lavender)
Accent: #211c44 (Port Gore - deep purple)
Logo Dark: #181838
*/
:root {
--wb-primary: #01040f;
--wb-secondary: #bcbeec;
--wb-accent: #211c44;
--wb-logo: #181838;
--wb-highlight: #8b7fef;
--wb-success: #7c6bef;
--wb-danger: #ef6b6b;
}
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: var(--wb-primary);
font-family: Arial, Helvetica, sans-serif;
user-select: none;
}
.video-container {
position: absolute;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
cursor: grab;
}
.video-container:active {
cursor: grabbing;
}
.video-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.overlay {
position: absolute;
top: 20px;
left: 20px;
background: rgba(33, 28, 68, 0.85);
backdrop-filter: blur(15px);
padding: 25px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(1, 4, 15, 0.6);
color: var(--wb-secondary);
border: 1px solid rgba(188, 190, 236, 0.15);
z-index: 100;
width: 320px;
max-height: calc(100vh - 40px);
overflow-y: auto;
box-sizing: border-box;
}
.overlay h2 {
margin: 0 0 15px 0;
font-size: 1.1em;
font-weight: 600;
letter-spacing: 0.5px;
color: #fff;
}
.control-group {
margin-bottom: 15px;
}
.control-group label {
display: block;
margin-bottom: 5px;
font-size: 0.8em;
opacity: 0.8;
color: var(--wb-secondary);
}
.slider-row {
display: flex;
align-items: center;
gap: 12px;
}
input[type=range] {
flex-grow: 1;
cursor: pointer;
accent-color: var(--wb-highlight);
}
.val-display {
width: 60px;
font-family: monospace;
font-size: 0.9em;
text-align: right;
color: #fff;
}
button {
flex: 1;
padding: 10px;
background: rgba(188, 190, 236, 0.1);
color: var(--wb-secondary);
border: 1px solid rgba(188, 190, 236, 0.25);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 0.85em;
}
button:hover {
background: rgba(188, 190, 236, 0.2);
border-color: rgba(188, 190, 236, 0.4);
}
button.danger {
background: rgba(239, 107, 107, 0.4);
border-color: rgba(239, 107, 107, 0.5);
}
button.danger:hover {
background: rgba(239, 107, 107, 0.6);
}
.stats {
margin-top: 15px;
font-size: 0.8em;
opacity: 0.9;
line-height: 1.6;
color: var(--wb-secondary);
}
.hint {
position: absolute;
bottom: 20px;
right: 20px;
color: rgba(188, 190, 236, 0.5);
font-size: 0.8em;
pointer-events: none;
text-align: right;
}
.rl-notifications {
position: absolute;
right: 24px;
bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 250;
}
.rl-notifications .notification {
min-width: 200px;
padding: 10px 14px;
background: rgba(9, 8, 29, 0.95);
border-radius: 12px;
border: 1px solid rgba(188, 190, 236, 0.25);
font-size: 0.75em;
color: #fff;
box-shadow: 0 10px 24px rgba(1, 4, 15, 0.5);
animation: toast-pop 0.35s ease;
}
@keyframes toast-pop {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.overlay-tiles {
position: absolute;
right: 20px;
bottom: 50px;
top: auto;
width: 240px;
display: none;
flex-direction: column;
gap: 10px;
z-index: 200;
pointer-events: none;
}
.overlay-tile {
width: 100%;
height: 140px;
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
}
.overlay-tile img {
width: 100%;
height: 100%;
object-fit: cover;
filter: saturate(1.1);
}
.overlay-label {
position: absolute;
bottom: 4px;
left: 6px;
right: 6px;
font-size: 0.65em;
text-transform: uppercase;
letter-spacing: 0.2em;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 0 6px rgba(0, 0, 0, 0.6);
}
.control-panel-info {
margin-top: 12px;
padding: 10px;
border: 1px solid rgba(188, 190, 236, 0.2);
border-radius: 8px;
background: rgba(188, 190, 236, 0.05);
font-size: 0.8em;
line-height: 1.4;
}
.control-panel-info ul {
margin: 6px 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.control-panel-info li {
list-style: none;
}
.hint-key {
display: flex;
align-items: center;
gap: 6px;
}
.hint-key kbd {
padding: 2px 6px;
border-radius: 4px;
background: rgba(139, 127, 239, 0.35);
border: 1px solid rgba(139, 127, 239, 0.55);
font-size: 0.75em;
color: #fff;
font-weight: 600;
}
/* State info panel - top right */
.state-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(33, 28, 68, 0.85);
backdrop-filter: blur(12px);
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 3px 14px rgba(1, 4, 15, 0.35);
color: var(--wb-secondary);
border: 1px solid rgba(188, 190, 236, 0.15);
z-index: 100;
min-width: 180px;
max-width: 220px;
font-size: 0.75em;
line-height: 1.3;
}
.state-panel strong {
color: #fff;
}
/* Camera controls - bottom left */
.camera-panel {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(33, 28, 68, 0.85);
backdrop-filter: blur(15px);
padding: 15px 20px;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(1, 4, 15, 0.5);
color: var(--wb-secondary);
border: 1px solid rgba(188, 190, 236, 0.15);
z-index: 100;
width: 280px;
}
.camera-panel .control-group {
margin-bottom: 10px;
}
.camera-panel .control-group:last-child {
margin-bottom: 0;
}
.state-hint {
margin-top: 12px;
font-size: 0.75em;
color: rgba(255, 255, 255, 0.7);
line-height: 1.4;
}
.camera-panel.inside-panel {
position: relative;
bottom: auto;
left: auto;
background: rgba(33, 28, 68, 0.75);
border: 1px solid rgba(188, 190, 236, 0.2);
box-shadow: none;
margin-top: 16px;
}
/* Collapsible panel header - title is the button */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
margin-bottom: 15px;
padding: 4px 0;
border-radius: 4px;
transition: all 0.2s;
}
.panel-header:hover {
background: rgba(188, 190, 236, 0.1);
}
.panel-header h2 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.panel-header h2::after {
content: '−';
font-weight: 300;
font-size: 1.2em;
opacity: 0.6;
transition: transform 0.2s;
}
.panel-header.collapsed h2::after {
content: '+';
}
.panel-content {
transition: all 0.3s ease;
overflow: hidden;
}
.panel-content.collapsed {
max-height: 0;
opacity: 0;
margin: 0;
padding: 0;
}
.overlay.collapsed {
width: auto;
min-width: 200px;
}
.rl-buttons {
display: flex;
flex-direction: column;
gap: 5px;
margin: 10px 0;
}
.rl-row {
display: flex;
justify-content: center;
gap: 5px;
}
.rl-btn {
padding: 12px 16px;
min-width: 80px;
background: rgba(124, 107, 239, 0.4);
border: 1px solid rgba(124, 107, 239, 0.5);
color: #fff;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
font-size: 0.85em;
font-weight: 500;
}
.rl-btn:hover {
background: rgba(124, 107, 239, 0.6);
}
.rl-btn:active {
background: rgba(124, 107, 239, 0.8);
transform: scale(0.95);
}
.rl-btn.stop {
background: rgba(239, 107, 107, 0.4);
border-color: rgba(239, 107, 107, 0.5);
}
.rl-btn.stop:hover {
background: rgba(239, 107, 107, 0.6);
}
.cmd-display {
font-family: monospace;
font-size: 0.8em;
opacity: 0.8;
margin-top: 8px;
color: var(--wb-secondary);
}
.connection-status-inline {
padding: 6px 12px;
border-radius: 4px;
font-size: 0.75em;
font-weight: 600;
background: rgba(124, 107, 239, 0.25);
color: #fff;
border: 1px solid rgba(124, 107, 239, 0.4);
margin-bottom: 12px;
text-align: center;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.connection-status-inline.disconnected {
background: rgba(239, 107, 107, 0.25);
border-color: rgba(239, 107, 107, 0.4);
}
.connection-status-inline .status-text {
display: inline-block;
}
.connection-status-inline .status-loader {
display: none;
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-top-color: #fff;
border-radius: 50%;
animation: status-spin 0.8s linear infinite;
margin-left: 8px;
}
.connection-status-inline.connecting {
background: rgba(139, 127, 239, 0.35);
border-color: rgba(255, 255, 255, 0.3);
}
.connection-status-inline.connecting .status-loader {
display: inline-block;
}
@keyframes status-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.status-card {
padding: 10px 12px;
border-radius: 8px;
border-left: 3px solid var(--wb-success);
background: rgba(76, 175, 80, 0.15);
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 10px;
transition: border-color 0.2s, background 0.2s;
}
.status-card strong {
font-size: 0.8em;
letter-spacing: 0.2em;
text-transform: uppercase;
color: #fff;
}
.status-card .status-card-text {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.8);
}
.status-card.disconnected {
background: rgba(239, 107, 107, 0.15);
border-color: rgba(239, 107, 107, 0.7);
}
.trainer-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.7em;
letter-spacing: 0.4px;
text-transform: uppercase;
}
.trainer-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(124, 107, 239, 0.6);
border: 1px solid rgba(255, 255, 255, 0.45);
}
.trainer-status.connected .trainer-status-dot {
background: #53e89b;
box-shadow: 0 0 6px rgba(83, 232, 155, 0.8);
}
.trainer-status.disconnected .trainer-status-dot {
background: #ef6b6b;
box-shadow: 0 0 6px rgba(239, 107, 107, 0.8);
}
.camera-controls {
margin-top: 15px;
}
select {
width: 100%;
padding: 10px;
background: rgba(188, 190, 236, 0.1);
color: #fff;
border: 1px solid rgba(188, 190, 236, 0.25);
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
}
select option {
background: var(--wb-accent);
color: #fff;
}
.nova-config-actions {
display: flex;
gap: 8px;
}
.nova-config-toggle {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid rgba(188, 190, 236, 0.35);
background: rgba(20, 18, 34, 0.65);
color: rgba(255, 255, 255, 0.8);
font-size: 0.95em;
cursor: pointer;
}
.nova-config-toggle:hover {
background: rgba(139, 127, 239, 0.25);
border-color: rgba(139, 127, 239, 0.55);
}
.nova-config-form {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
box-sizing: border-box;
}
.nova-config-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.nova-config-row label {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.75);
}
.nova-config-row input[type="text"],
.nova-config-row input[type="password"] {
width: 100%;
padding: 8px 10px;
background: rgba(188, 190, 236, 0.1);
color: #fff;
border: 1px solid rgba(188, 190, 236, 0.25);
border-radius: 6px;
font-size: 0.85em;
box-sizing: border-box;
}
.nova-config-status {
font-size: 0.75em;
color: rgba(255, 255, 255, 0.75);
min-height: 1em;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(6, 6, 15, 0.72);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 24px;
}
.modal-backdrop.active {
display: flex;
}
.modal-card {
width: min(480px, 100%);
background: rgba(20, 18, 34, 0.96);
border: 1px solid rgba(139, 127, 239, 0.45);
border-radius: 12px;
padding: 18px;
box-shadow: 0 18px 40px rgba(8, 8, 20, 0.55);
box-sizing: border-box;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.modal-header h3 {
margin: 0;
font-size: 1em;
}
.modal-close {
border: none;
background: transparent;
color: #fff;
font-size: 0.8em;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
}
.nova-badge-row {
display: flex;
align-items: center;
gap: 8px;
}
.robot-selector {
margin-bottom: 20px;
}
.scene-summary {
margin-top: 12px;
font-size: 0.8em;
display: flex;
align-items: baseline;
gap: 6px;
color: rgba(255, 255, 255, 0.75);
}
.scene-summary strong {
font-size: 0.9em;
opacity: 0.95;
}
.robot-info {
padding: 10px;
background: rgba(139, 127, 239, 0.2);
border-radius: 6px;
margin-top: 10px;
font-size: 0.8em;
border: 1px solid rgba(139, 127, 239, 0.3);
}
.arm-controls {
display: none;
}
.arm-controls.active {
display: block;
}
.locomotion-controls {
display: block;
}
.locomotion-controls.hidden {
display: none;
}
.gripper-btns {
display: flex;
gap: 10px;
margin: 10px 0;
}
.gripper-btns button {
flex: 1;
}
.target-sliders {
margin: 10px 0;
}
.target-sliders .slider-row {
margin-bottom: 8px;
}
.target-sliders label {
width: 20px;
display: inline-block;
}
.mode-toggle {
display: flex;
gap: 5px;
margin-bottom: 15px;
}
.mode-toggle button {
flex: 1;
padding: 8px;
background: rgba(188, 190, 236, 0.1);
border: 1px solid rgba(188, 190, 236, 0.2);
}
.mode-toggle button.active {
background: rgba(139, 127, 239, 0.5);
border-color: rgba(139, 127, 239, 0.7);
}
.ik-controls,
.joint-controls {
display: none;
}
.ik-controls.active,
.joint-controls.active {
display: block;
}
.joint-sliders .slider-row {
margin-bottom: 6px;
}
.joint-sliders label {
width: 40px;
display: inline-block;
font-size: 0.75em;
}
/* Jogging controls */
.jog-controls {
margin: 10px 0;
}
.jog-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.jog-row label {
width: 30px;
display: inline-block;
font-size: 0.85em;
text-align: left;
}
.jog-btn {
width: 40px;
height: 40px;
padding: 0;
font-size: 1.5em;
font-weight: bold;
background: rgba(188, 190, 236, 0.15);
color: var(--wb-secondary);
border: 1px solid rgba(188, 190, 236, 0.3);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
user-select: none;
-webkit-user-select: none;
flex-shrink: 0;
}
.jog-btn:hover {
background: rgba(139, 127, 239, 0.3);
border-color: rgba(139, 127, 239, 0.5);
transform: scale(1.05);
}
.jog-btn:active {
background: rgba(139, 127, 239, 0.5);
border-color: rgba(139, 127, 239, 0.7);
transform: scale(0.95);
}
.jog-row .val-display {
flex-grow: 1;
width: auto;
text-align: center;
font-family: monospace;
font-size: 0.9em;
color: #fff;
}
</style>
</head>
<body>
<div class="video-container" id="viewport">
<img src=\"""" + API_PREFIX + """/video_feed" draggable="false">
</div>
<!-- State info panel - top right -->
<div class="state-panel" id="state_panel">
<!-- Connection status badge (inside panel) -->
<div class="connection-status-inline connecting" id="conn_status">
<span class="status-text" id="conn_status_text">Connecting...</span>
<span class="status-loader" id="conn_loader" aria-hidden="true"></span>
</div>
<div id="locomotion_state">
<strong>Robot State</strong><br>
Position: <span id="loco_pos">0.00, 0.00, 0.00</span><br>
Orientation: <span id="loco_ori">0.00, 0.00, 0.00</span><br>
Steps: <span id="step_val">0</span><br>
Teleop: <span id="loco_teleop_display"
style="display: inline-block; word-wrap: break-word; max-width: 100%;">-</span>
</div>
<div id="arm_state" style="display: none;">
<strong>Arm State</strong><br>
EE Pos: <span id="ee_pos">0.00, 0.00, 0.00</span><br>
EE Ori: <span id="ee_ori">0.00, 0.00, 0.00</span><br>
<span id="gripper_state_display">Gripper: <span id="gripper_val">50%</span><br></span>
Reward: <span id="arm_reward">-</span><br>
Mode: <span id="control_mode_display">IK</span> | Steps: <span id="arm_step_val">0</span><br>
Teleop: <span id="arm_teleop_display"
style="display: inline-block; word-wrap: break-word; max-width: 100%;">-</span><br>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(188, 190, 236, 0.2);">
<div id="nova_connection_badge" class="status-card" style="display: none; margin-bottom: 8px;">
<div class="nova-badge-row">
<strong id="nova_badge_title"
style="font-size: 0.85em; color: var(--wb-success);">
Nova Connected
</strong>
<button class="nova-config-toggle" id="nova_override_toggle" title="Update Nova credentials" aria-label="Update Nova credentials"></button>
</div>
</div>
</div>
</div>
<div id="clients_status_card" class="status-card">
<strong>Connected Clients</strong>
<div class="clients-status" id="clients_status_indicator">
<span class="status-card-text" id="clients_status_text">No clients connected</span>
<ul id="clients_list" style="margin: 0.5em 0 0 0; padding-left: 1.5em; font-size: 0.9em;"></ul>
</div>
</div>
</div>
<div class="overlay-tiles" id="overlay_tiles"></div>
<div class="overlay" id="control_panel">
<div class="panel-header" id="panel_header" onclick="togglePanel()">
<h2 id="robot_title">Unitree G1 Humanoid</h2>
</div>
<div class="panel-content" id="panel_content">
<div class="robot-selector">
<select id="robot_select" onchange="switchRobot()">
<option value="">Loading robots...</option>
</select>
<div class="control-panel-info" id="arm_hints" style="display: none;">
<strong>Keyboard Controls</strong>
<ul>
<li>
<span class="hint-key">
<kbd>W/A/S/D</kbd>
<span>XY jog</span>
</span>
</li>
<li>
<span class="hint-key">
<kbd>R/F</kbd>
<span>Z nudge</span>
</span>
</li>
<li>
<span class="hint-key">
<kbd>Enter</kbd>
<span>Move to Home</span>
</span>
</li>
</ul>
</div>
<div class="control-panel-info" id="loco_hints" style="display: none;">
<strong>Keyboard Controls</strong>
<ul>
<li>
<span class="hint-key">
<kbd>W/S</kbd>
<span>Forward/Back</span>
</span>
</li>
<li>
<span class="hint-key">
<kbd>A/D</kbd>
<span>Turn Left/Right</span>
</span>
</li>
<li>
<span class="hint-key">
<kbd>Q/E</kbd>
<span>Strafe Left/Right</span>
</span>
</li>
</ul>
</div>
<div class="robot-info" id="robot_info">
29 DOF humanoid with RL walking policy
</div>
</div>
<!-- Locomotion controls (G1, Spot) -->
<div class="locomotion-controls" id="locomotion_controls">
<div class="control-group">
<label>Walking Controls (WASD or buttons)</label>
<div class="rl-buttons">
<div class="rl-row">
<button class="rl-btn" onmousedown="setCmd(0.8, 0, 0)" onmouseup="setCmd(0,0,0)"
ontouchstart="setCmd(0.8, 0, 0)" ontouchend="setCmd(0,0,0)">W Forward</button>
</div>
<div class="rl-row">
<button class="rl-btn" onmousedown="setCmd(0, 0, 1.2)" onmouseup="setCmd(0,0,0)"
ontouchstart="setCmd(0, 0, 1.2)" ontouchend="setCmd(0,0,0)">A Turn L</button>
<button class="rl-btn" onmousedown="setCmd(-0.5, 0, 0)" onmouseup="setCmd(0,0,0)"
ontouchstart="setCmd(-0.5, 0, 0)" ontouchend="setCmd(0,0,0)">S Back</button>
<button class="rl-btn" onmousedown="setCmd(0, 0, -1.2)" onmouseup="setCmd(0,0,0)"
ontouchstart="setCmd(0, 0, -1.2)" ontouchend="setCmd(0,0,0)">D Turn R</button>
</div>
<div class="rl-row">
<button class="rl-btn" onmousedown="setCmd(0, 0.4, 0)" onmouseup="setCmd(0,0,0)"
ontouchstart="setCmd(0, 0.4, 0)" ontouchend="setCmd(0,0,0)">Q Strafe L</button>
<button class="rl-btn stop" onclick="setCmd(0, 0, 0)">Stop</button>
<button class="rl-btn" onmousedown="setCmd(0, -0.4, 0)" onmouseup="setCmd(0,0,0)"
ontouchstart="setCmd(0, -0.4, 0)" ontouchend="setCmd(0,0,0)">E Strafe R</button>
</div>
</div>
</div>
</div>
<!-- Arm controls (UR5) -->
<div class="arm-controls" id="arm_controls">
<div class="control-group">
<label>Control Mode</label>
<div class="mode-toggle">
<button id="mode_ik" class="active" onclick="setControlMode('ik')">IK (XYZ Target)</button>
<button id="mode_joint" onclick="setControlMode('joint')">Direct Joints</button>
</div>
</div>
<!-- IK Controls -->
<div class="ik-controls active" id="ik_controls">
<div class="control-group">
<label>Translation (XYZ)</label>
<div class="jog-controls">
<div class="jog-row">
<label style="color: #ff6b6b; font-weight: bold;">X</label>
<button class="jog-btn" onmousedown="startJog('cartesian_translation', 'x', '-')"
onmouseup="stopJog('cartesian_translation', 'x', '-')"
ontouchstart="startJog('cartesian_translation', 'x', '-'); event.preventDefault()"
ontouchend="stopJog('cartesian_translation', 'x', '-')"></button>
<span class="val-display" id="pos_x_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('cartesian_translation', 'x', '+')"
onmouseup="stopJog('cartesian_translation', 'x', '+')"
ontouchstart="startJog('cartesian_translation', 'x', '+'); event.preventDefault()"
ontouchend="stopJog('cartesian_translation', 'x', '+')">+</button>
</div>
<div class="jog-row">
<label style="color: #51cf66; font-weight: bold;">Y</label>
<button class="jog-btn" onmousedown="startJog('cartesian_translation', 'y', '-')"
onmouseup="stopJog('cartesian_translation', 'y', '-')"
ontouchstart="startJog('cartesian_translation', 'y', '-'); event.preventDefault()"
ontouchend="stopJog('cartesian_translation', 'y', '-')"></button>
<span class="val-display" id="pos_y_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('cartesian_translation', 'y', '+')"
onmouseup="stopJog('cartesian_translation', 'y', '+')"
ontouchstart="startJog('cartesian_translation', 'y', '+'); event.preventDefault()"
ontouchend="stopJog('cartesian_translation', 'y', '+')">+</button>
</div>
<div class="jog-row">
<label style="color: #339af0; font-weight: bold;">Z</label>
<button class="jog-btn" onmousedown="startJog('cartesian_translation', 'z', '-')"
onmouseup="stopJog('cartesian_translation', 'z', '-')"
ontouchstart="startJog('cartesian_translation', 'z', '-'); event.preventDefault()"
ontouchend="stopJog('cartesian_translation', 'z', '-')"></button>
<span class="val-display" id="pos_z_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('cartesian_translation', 'z', '+')"
onmouseup="stopJog('cartesian_translation', 'z', '+')"
ontouchstart="startJog('cartesian_translation', 'z', '+'); event.preventDefault()"
ontouchend="stopJog('cartesian_translation', 'z', '+')">+</button>
</div>
</div>
<div style="margin-top: 8px;">
<label style="font-size: 0.85em;">Velocity: <span id="trans_vel_val">5</span> mm/s</label>
<input type="range" id="trans_velocity" min="1" max="100" step="1" value="5"
oninput="updateTransVelocity()" style="width: 100%;">
</div>
</div>
<div class="control-group">
<label>Rotation (RPY)</label>
<div class="jog-controls">
<div class="jog-row">
<label style="color: #ff6b6b; font-weight: bold;">Rx</label>
<button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'x', '-')"
onmouseup="stopJog('cartesian_rotation', 'x', '-')"
ontouchstart="startJog('cartesian_rotation', 'x', '-'); event.preventDefault()"
ontouchend="stopJog('cartesian_rotation', 'x', '-')"></button>
<span class="val-display" id="rot_x_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'x', '+')"
onmouseup="stopJog('cartesian_rotation', 'x', '+')"
ontouchstart="startJog('cartesian_rotation', 'x', '+'); event.preventDefault()"
ontouchend="stopJog('cartesian_rotation', 'x', '+')">+</button>
</div>
<div class="jog-row">
<label style="color: #51cf66; font-weight: bold;">Ry</label>
<button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'y', '-')"
onmouseup="stopJog('cartesian_rotation', 'y', '-')"
ontouchstart="startJog('cartesian_rotation', 'y', '-'); event.preventDefault()"
ontouchend="stopJog('cartesian_rotation', 'y', '-')"></button>
<span class="val-display" id="rot_y_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'y', '+')"
onmouseup="stopJog('cartesian_rotation', 'y', '+')"
ontouchstart="startJog('cartesian_rotation', 'y', '+'); event.preventDefault()"
ontouchend="stopJog('cartesian_rotation', 'y', '+')">+</button>
</div>
<div class="jog-row">
<label style="color: #339af0; font-weight: bold;">Rz</label>
<button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'z', '-')"
onmouseup="stopJog('cartesian_rotation', 'z', '-')"
ontouchstart="startJog('cartesian_rotation', 'z', '-'); event.preventDefault()"
ontouchend="stopJog('cartesian_rotation', 'z', '-')"></button>
<span class="val-display" id="rot_z_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('cartesian_rotation', 'z', '+')"
onmouseup="stopJog('cartesian_rotation', 'z', '+')"
ontouchstart="startJog('cartesian_rotation', 'z', '+'); event.preventDefault()"
ontouchend="stopJog('cartesian_rotation', 'z', '+')">+</button>
</div>
</div>
<div style="margin-top: 8px;">
<label style="font-size: 0.85em;">Velocity: <span id="rot_vel_val">0.3</span> rad/s</label>
<input type="range" id="rot_velocity" min="0.1" max="1.0" step="0.1" value="0.3"
oninput="updateRotVelocity()" style="width: 100%;">
</div>
</div>
</div>
<!-- Joint Controls -->
<div class="joint-controls" id="joint_controls">
<div class="control-group">
<label>Joint Positions</label>
<div class="jog-controls">
<div class="jog-row">
<label>J1</label>
<button class="jog-btn" onmousedown="startJog('joint', 1, '-')" onmouseup="stopJog('joint', 1, '-')"
ontouchstart="startJog('joint', 1, '-'); event.preventDefault()"
ontouchend="stopJog('joint', 1, '-')"></button>
<span class="val-display" id="joint_0_val">-1.57</span>
<button class="jog-btn" onmousedown="startJog('joint', 1, '+')" onmouseup="stopJog('joint', 1, '+')"
ontouchstart="startJog('joint', 1, '+'); event.preventDefault()"
ontouchend="stopJog('joint', 1, '+')">+</button>
</div>
<div class="jog-row">
<label>J2</label>
<button class="jog-btn" onmousedown="startJog('joint', 2, '-')" onmouseup="stopJog('joint', 2, '-')"
ontouchstart="startJog('joint', 2, '-'); event.preventDefault()"
ontouchend="stopJog('joint', 2, '-')"></button>
<span class="val-display" id="joint_1_val">-1.57</span>
<button class="jog-btn" onmousedown="startJog('joint', 2, '+')" onmouseup="stopJog('joint', 2, '+')"
ontouchstart="startJog('joint', 2, '+'); event.preventDefault()"
ontouchend="stopJog('joint', 2, '+')">+</button>
</div>
<div class="jog-row">
<label>J3</label>
<button class="jog-btn" onmousedown="startJog('joint', 3, '-')" onmouseup="stopJog('joint', 3, '-')"
ontouchstart="startJog('joint', 3, '-'); event.preventDefault()"
ontouchend="stopJog('joint', 3, '-')"></button>
<span class="val-display" id="joint_2_val">1.57</span>
<button class="jog-btn" onmousedown="startJog('joint', 3, '+')" onmouseup="stopJog('joint', 3, '+')"
ontouchstart="startJog('joint', 3, '+'); event.preventDefault()"
ontouchend="stopJog('joint', 3, '+')">+</button>
</div>
<div class="jog-row">
<label>J4</label>
<button class="jog-btn" onmousedown="startJog('joint', 4, '-')" onmouseup="stopJog('joint', 4, '-')"
ontouchstart="startJog('joint', 4, '-'); event.preventDefault()"
ontouchend="stopJog('joint', 4, '-')"></button>
<span class="val-display" id="joint_3_val">-1.57</span>
<button class="jog-btn" onmousedown="startJog('joint', 4, '+')" onmouseup="stopJog('joint', 4, '+')"
ontouchstart="startJog('joint', 4, '+'); event.preventDefault()"
ontouchend="stopJog('joint', 4, '+')">+</button>
</div>
<div class="jog-row">
<label>J5</label>
<button class="jog-btn" onmousedown="startJog('joint', 5, '-')" onmouseup="stopJog('joint', 5, '-')"
ontouchstart="startJog('joint', 5, '-'); event.preventDefault()"
ontouchend="stopJog('joint', 5, '-')"></button>
<span class="val-display" id="joint_4_val">-1.57</span>
<button class="jog-btn" onmousedown="startJog('joint', 5, '+')" onmouseup="stopJog('joint', 5, '+')"
ontouchstart="startJog('joint', 5, '+'); event.preventDefault()"
ontouchend="stopJog('joint', 5, '+')">+</button>
</div>
<div class="jog-row">
<label>J6</label>
<button class="jog-btn" onmousedown="startJog('joint', 6, '-')" onmouseup="stopJog('joint', 6, '-')"
ontouchstart="startJog('joint', 6, '-'); event.preventDefault()"
ontouchend="stopJog('joint', 6, '-')"></button>
<span class="val-display" id="joint_5_val">0.00</span>
<button class="jog-btn" onmousedown="startJog('joint', 6, '+')" onmouseup="stopJog('joint', 6, '+')"
ontouchstart="startJog('joint', 6, '+'); event.preventDefault()"
ontouchend="stopJog('joint', 6, '+')">+</button>
</div>
</div>
<div style="margin-top: 8px;">
<label style="font-size: 0.85em;">Velocity: <span id="joint_vel_val">0.5</span>
rad/s</label>
<input type="range" id="joint_velocity" min="0.1" max="2.0" step="0.1" value="0.5"
oninput="updateJointVelocity()" style="width: 100%;">
</div>
</div>
</div>
<div class="control-group" id="gripper_controls">
<label>Gripper</label>
<div class="gripper-btns">
<button class="rl-btn" onclick="setGripper('open')">Open</button>
<button class="rl-btn" onclick="setGripper('close')">Close</button>
</div>
</div>
<div class="control-group">
<label>Camera Distance</label>
<div class="slider-row">
<input type="range" id="cam_dist" min="1" max="10" step="0.1" value="3.0">
<span class="val-display" id="cam_dist_val">3.0</span>
</div>
</div>
<div class="control-group">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="cam_follow" checked onchange="setCameraFollow()">
<span id="cam_follow_label">Camera Follow Robot</span>
</label>
</div>
<div class="control-group" id="target_visibility_control" style="display: none;">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
<input type="checkbox" id="target_visible" checked onchange="setTargetVisibility()">
Show Target T
</label>
</div>
</div>
<button class="danger" style="width: 100%; margin-top: 15px;" onclick="resetEnv()">Reset
Environment</button>
<button id="homeBtn" style="width: 100%; margin-top: 10px; display: none;" onclick="startHoming()">🏠 Move to Home</button>
</div>
</div>
<div class="modal-backdrop" id="nova_config_modal" aria-hidden="true">
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="nova_modal_title">
<div class="modal-header">
<h3 id="nova_modal_title">Nova Credentials</h3>
<button class="modal-close" id="nova_modal_close" aria-label="Close">Close</button>
</div>
<div class="nova-config-form">
<div class="nova-config-row">
<label for="nova_instance_url">Instance URL</label>
<input type="text" id="nova_instance_url" placeholder="https://nova.wandelbots.io">
</div>
<div class="nova-config-row">
<label for="nova_access_token">Access Token</label>
<input type="password" id="nova_access_token" placeholder="Paste access token">
</div>
<div class="nova-config-row">
<label for="nova_cell_id">Cell ID</label>
<input type="text" id="nova_cell_id" placeholder="cell">
</div>
<div class="nova-config-row">
<label for="nova_controller_id">Controller ID</label>
<input type="text" id="nova_controller_id" placeholder="controller id">
</div>
<div class="nova-config-row">
<label for="nova_motion_group_id">Motion Group ID</label>
<input type="text" id="nova_motion_group_id" placeholder="motion group id">
</div>
<div class="nova-config-actions">
<button class="rl-btn" id="nova_apply_button">Apply & Reconnect</button>
</div>
<div class="nova-config-status" id="nova_config_status"></div>
</div>
</div>
</div>
<div class="rl-notifications" id="rl_notifications_list"></div>
<div class="hint" id="hint_box" style="display: none;">
Drag: Rotate Camera<br>
Scroll: Zoom
</div>
<script>
const API_PREFIX = '""" + API_PREFIX + """';
const WS_URL = (window.location.protocol === 'https:' ? 'wss://' : 'ws://') +
window.location.host + API_PREFIX + '/ws';
let ws = null;
let reconnectTimer = null;
const connStatus = document.getElementById('conn_status');
const connStatusText = document.getElementById('conn_status_text');
const robotSelect = document.getElementById('robot_select');
const sceneLabel = document.getElementById('scene_label');
const overlayTiles = document.getElementById('overlay_tiles');
const clientsStatusCard = document.getElementById('clients_status_card');
const clientsStatusIndicator = document.getElementById('clients_status_indicator');
const clientsStatusText = document.getElementById('clients_status_text');
const clientsList = document.getElementById('clients_list');
const viewportImage = document.querySelector('.video-container img');
const robotTitle = document.getElementById('robot_title');
const robotInfo = document.getElementById('robot_info');
const metadataUrl = API_PREFIX + '/metadata';
const envUrl = API_PREFIX + '/env';
let metadataCache = null;
let envCache = null;
let pendingRobotSelection = null;
let currentRobot = null;
let currentScene = null;
let novaStateStreaming = false; // Track if Nova state streaming is active
let currentHomePose = null; // Store home pose from env data
let currentJointPositions = null; // Store latest joint positions from state stream
let prevNovaConnected = null; // Track previous Nova connection state for error detection
// Track previous Nova API state to avoid unnecessary UI updates
let prevNovaState = {
available: null,
enabled: null,
connected: null,
state_streaming: null,
ik: null
};
function updateConnectionLabel(text) {
if (connStatusText) {
connStatusText.innerText = text;
}
}
function enterConnectingState() {
updateConnectionLabel('Connecting...');
if (connStatus) {
connStatus.classList.add('connecting');
connStatus.classList.remove('disconnected');
}
}
function markConnectedState() {
updateConnectionLabel('Connected');
if (connStatus) {
connStatus.classList.remove('connecting', 'disconnected');
}
}
function refreshVideoStreams() {
const timestamp = Date.now();
if (viewportImage) {
viewportImage.src = `${API_PREFIX}/video_feed?ts=${timestamp}`;
}
if (overlayTiles) {
overlayTiles.querySelectorAll('img[data-feed]').forEach((img) => {
const feedName = img.dataset.feed || 'main';
img.src = `${API_PREFIX}/camera/${feedName}/video_feed?ts=${timestamp}`;
});
}
}
const novaToggleButton = document.getElementById('nova_toggle_button');
const novaOverrideToggle = document.getElementById('nova_override_toggle');
const novaConfigModal = document.getElementById('nova_config_modal');
const novaModalClose = document.getElementById('nova_modal_close');
const novaApplyButton = document.getElementById('nova_apply_button');
const novaConfigStatus = document.getElementById('nova_config_status');
const novaInstanceUrlInput = document.getElementById('nova_instance_url');
const novaAccessTokenInput = document.getElementById('nova_access_token');
const novaCellIdInput = document.getElementById('nova_cell_id');
const novaControllerIdInput = document.getElementById('nova_controller_id');
const novaMotionGroupIdInput = document.getElementById('nova_motion_group_id');
let novaEnabledState = false;
let novaManualToggle = false;
let novaAutoEnableRequested = false;
let novaPreconfigured = false;
function setNovaToggleState(available, enabled) {
if (!novaToggleButton) {
return;
}
novaEnabledState = !!enabled;
if (novaEnabledState) {
novaAutoEnableRequested = false;
}
if (!available) {
novaAutoEnableRequested = false;
}
novaToggleButton.style.display = available ? 'inline-flex' : 'none';
novaToggleButton.disabled = !available;
novaToggleButton.innerText = novaEnabledState ? 'Turn Nova Off' : 'Turn Nova On';
}
if (novaToggleButton) {
novaToggleButton.addEventListener('click', () => {
novaManualToggle = true;
send('set_nova_mode', { enabled: !novaEnabledState });
});
}
function setNovaConfigStatus(message, isError = false) {
if (!novaConfigStatus) {
return;
}
novaConfigStatus.innerText = message || '';
novaConfigStatus.style.color = isError ? '#ef6b6b' : 'rgba(255, 255, 255, 0.75)';
}
function openNovaModal() {
if (!novaConfigModal) {
return;
}
novaConfigModal.classList.add('active');
novaConfigModal.setAttribute('aria-hidden', 'false');
setNovaConfigStatus('');
if (novaAccessTokenInput) {
novaAccessTokenInput.focus();
}
}
function closeNovaModal() {
if (!novaConfigModal) {
return;
}
novaConfigModal.classList.remove('active');
novaConfigModal.setAttribute('aria-hidden', 'true');
}
if (novaOverrideToggle) {
novaOverrideToggle.addEventListener('click', () => {
openNovaModal();
});
}
if (novaModalClose) {
novaModalClose.addEventListener('click', closeNovaModal);
}
if (novaConfigModal) {
novaConfigModal.addEventListener('click', (event) => {
if (event.target === novaConfigModal) {
closeNovaModal();
}
});
}
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && novaConfigModal && novaConfigModal.classList.contains('active')) {
closeNovaModal();
}
});
if (novaApplyButton) {
novaApplyButton.addEventListener('click', (event) => {
event.preventDefault();
const payload = {};
if (novaInstanceUrlInput && novaInstanceUrlInput.value.trim()) {
payload.instance_url = novaInstanceUrlInput.value.trim();
}
if (novaAccessTokenInput && novaAccessTokenInput.value.trim()) {
payload.access_token = novaAccessTokenInput.value.trim();
}
if (novaCellIdInput && novaCellIdInput.value.trim()) {
payload.cell_id = novaCellIdInput.value.trim();
}
if (novaControllerIdInput && novaControllerIdInput.value.trim()) {
payload.controller_id = novaControllerIdInput.value.trim();
}
if (novaMotionGroupIdInput && novaMotionGroupIdInput.value.trim()) {
payload.motion_group_id = novaMotionGroupIdInput.value.trim();
}
if (Object.keys(payload).length === 0) {
setNovaConfigStatus('Enter at least one field to override.', true);
return;
}
setNovaConfigStatus('Updating Nova credentials...');
fetch(`${API_PREFIX}/nova_config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
.then(async (response) => {
const data = await response.json().catch(() => ({}));
return { ok: response.ok, data };
})
.then(({ ok, data }) => {
if (!ok || !data || data.ok === false) {
const errorMessage = (data && data.error) ? data.error : 'Failed to update Nova credentials.';
setNovaConfigStatus(errorMessage, true);
return;
}
novaPreconfigured = !!data.preconfigured;
setNovaToggleState(novaPreconfigured, novaEnabledState);
setNovaConfigStatus('Credentials updated. Reconnecting...');
if (ws && ws.readyState === WebSocket.OPEN) {
send('set_nova_mode', { enabled: true });
}
closeNovaModal();
})
.catch((err) => {
console.error('Failed to update Nova credentials:', err);
setNovaConfigStatus('Failed to update Nova credentials.', true);
});
});
}
const NOVA_TRANSLATION_VELOCITY = 50.0;
const NOVA_ROTATION_VELOCITY = 0.3;
const NOVA_JOINT_VELOCITY = 0.5;
let novaVelocitiesConfigured = false;
function updateConnectedClients(clients, unidentifiedClients = []) {
if (!clientsStatusCard || !clientsStatusText || !clientsList) {
return;
}
// Update text and list
const totalClients = (clients || []).length + (unidentifiedClients || []).length;
if (totalClients === 0) {
clientsStatusText.innerText = "No clients connected";
clientsList.innerHTML = "";
clientsList.style.display = "none";
} else {
clientsStatusText.innerText = `${totalClients} client${totalClients > 1 ? 's' : ''} connected`;
// Build list with identified clients first, then unidentified
let html = '';
if (clients && clients.length > 0) {
html += clients.map(id => `<li><strong>${id}</strong></li>`).join('');
}
if (unidentifiedClients && unidentifiedClients.length > 0) {
html += unidentifiedClients.map(id => `<li style="color: #888; font-style: italic;">${id} (not identified)</li>`).join('');
}
clientsList.innerHTML = html;
clientsList.style.display = "block";
}
}
// Legacy function for backward compatibility with old state messages
function updateTrainerStatus(connected) {
// Deprecated: convert to new format
updateConnectedClients(connected ? ["legacy-client"] : []);
}
function configureNovaVelocities() {
if (novaVelocitiesConfigured) {
return;
}
const transSlider = document.getElementById('trans_velocity');
const rotSlider = document.getElementById('rot_velocity');
const jointSlider = document.getElementById('joint_velocity');
if (transSlider) {
transSlider.value = NOVA_TRANSLATION_VELOCITY;
updateTransVelocity();
}
if (rotSlider) {
rotSlider.value = NOVA_ROTATION_VELOCITY;
updateRotVelocity();
}
if (jointSlider) {
jointSlider.value = NOVA_JOINT_VELOCITY;
updateJointVelocity();
}
novaVelocitiesConfigured = true;
}
function refreshOverlayTiles() {
if (!metadataCache) {
return;
}
setupOverlayTiles();
}
function handleCameraEvent(eventPayload) {
if (!eventPayload || !eventPayload.camera) {
return;
}
const scope = eventPayload.scope || {};
if (scope.robot && currentRobot && scope.robot !== currentRobot) {
return;
}
if (scope.scene && currentScene && scope.scene !== currentScene) {
return;
}
fetch(envUrl)
.then(r => r.json())
.then(envData => {
envCache = envData;
refreshOverlayTiles();
refreshVideoStreams();
});
}
const robotInfoText = {
'g1': '29 DOF humanoid with RL walking policy',
'spot': '12 DOF quadruped with trot gait controller',
'ur5': '6 DOF robot arm with Robotiq gripper',
'ur5_t_push': 'UR5e T-push task with stick tool'
};
const robotTitles = {
'g1': 'Unitree G1 Humanoid',
'spot': 'Boston Dynamics Spot',
'ur5': 'Universal Robots UR5e',
'ur5_t_push': 'UR5e T-Push Scene'
};
const locomotionControls = document.getElementById('locomotion_controls');
const armControls = document.getElementById('arm_controls');
const notificationList = document.getElementById('rl_notifications_list');
const armRobotTypes = new Set(['ur5', 'ur5_t_push']);
const armTeleopKeys = new Set(['KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyR', 'KeyF']);
const locomotionKeys = new Set([
'KeyW', 'KeyA', 'KeyS', 'KeyD', 'KeyQ', 'KeyE',
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'
]);
function maybeAutoEnableNova() {
if (!novaPreconfigured || novaManualToggle) {
return;
}
const activeSelection = getActiveSelection();
if (!armRobotTypes.has(activeSelection.robot)) {
return;
}
if (novaEnabledState) {
novaAutoEnableRequested = false;
return;
}
if (novaAutoEnableRequested) {
return;
}
if (ws && ws.readyState === WebSocket.OPEN) {
send('set_nova_mode', { enabled: true });
novaAutoEnableRequested = true;
}
}
let teleopTranslationStep = 0.005; // meters per keyboard nudge
let teleopVerticalStep = 0.01;
const TELEOP_REPEAT_INTERVAL_MS = 80;
const NOTIFICATION_DURATION_MS = 5000;
let teleopRepeatTimer = null;
let lastTeleopCommand = { dx: 0, dy: 0, dz: 0 };
const armJogKeyMap = {
KeyW: { jogType: 'cartesian_translation', axis: 'x', direction: '+' },
KeyS: { jogType: 'cartesian_translation', axis: 'x', direction: '-' },
KeyA: { jogType: 'cartesian_translation', axis: 'y', direction: '+' },
KeyD: { jogType: 'cartesian_translation', axis: 'y', direction: '-' },
KeyR: { jogType: 'cartesian_translation', axis: 'z', direction: '+' },
KeyF: { jogType: 'cartesian_translation', axis: 'z', direction: '-' },
};
function humanizeScene(scene) {
if (!scene) {
return 'Default';
}
const cleaned = scene.replace(/^scene_/, '').replace(/_/g, ' ');
return cleaned.replace(/\\b\\w/g, (char) => char.toUpperCase());
}
function buildSelectionValue(robot, scene) {
return scene ? `${robot}::${scene}` : robot;
}
function parseSelection(value) {
if (!value) {
return { robot: 'g1', scene: null };
}
const [robotPart, scenePart] = value.split('::');
return { robot: robotPart, scene: scenePart || null };
}
function createRobotSceneOption(robot, scene, label) {
const option = document.createElement("option");
option.value = buildSelectionValue(robot, scene);
const sceneText = scene ? ` · ${humanizeScene(scene)}` : '';
option.textContent = `${label || robot}${sceneText}`;
return option;
}
function getDefaultSelection(metadata) {
if (!metadata) {
return '';
}
const robots = Object.keys(metadata.robots);
if (!robots.length) {
return '';
}
const preferred = metadata.robots['ur5_t_push'] ? 'ur5_t_push' : robots[0];
const preferredRobot = metadata.robots[preferred];
const preferredScenes = preferredRobot && preferredRobot.scenes;
const defaultScene = (metadata.default_scene && metadata.default_scene[preferred])
|| (preferredScenes && preferredScenes[0]) || '';
return buildSelectionValue(preferred, defaultScene);
}
function populateRobotOptions(metadata) {
if (!metadata) {
return null;
}
robotSelect.innerHTML = "";
Object.entries(metadata.robots).forEach(([robot, meta]) => {
const scenes = meta.scenes || [];
if (scenes.length <= 1) {
const scene = scenes[0] || "";
robotSelect.appendChild(createRobotSceneOption(robot, scene, meta.label));
} else {
const group = document.createElement("optgroup");
group.label = meta.label || robot;
scenes.forEach((scene) => {
group.appendChild(createRobotSceneOption(robot, scene, meta.label));
});
robotSelect.appendChild(group);
}
});
const defaultValue = getDefaultSelection(metadata);
if (defaultValue) {
robotSelect.value = defaultValue;
const parsed = parseSelection(defaultValue);
return parsed;
}
return null;
}
async function setupOverlayTiles() {
if (!overlayTiles) {
return;
}
if (!envCache) {
overlayTiles.innerHTML = "";
overlayTiles.dataset.overlayKey = "";
overlayTiles.style.display = 'none';
return;
}
// Get overlay camera feeds from envCache (excludes main camera)
const allFeeds = envCache.camera_feeds || [];
const overlayFeeds = allFeeds.filter(feed => feed.name !== 'main');
if (!overlayFeeds.length) {
overlayTiles.innerHTML = "";
overlayTiles.dataset.overlayKey = "";
overlayTiles.style.display = 'none';
return;
}
const feedNames = overlayFeeds.map((feed) => feed.name || "aux");
const key = `${envCache.robot || ''}|${envCache.scene || ''}|${feedNames.join(',')}`;
if (overlayTiles.dataset.overlayKey === key) {
overlayTiles.style.display = 'flex';
return;
}
overlayTiles.dataset.overlayKey = key;
overlayTiles.innerHTML = "";
overlayTiles.style.display = 'flex';
overlayFeeds.forEach((feed) => {
const tile = document.createElement("div");
tile.className = "overlay-tile";
const img = document.createElement("img");
const feedName = feed.name || "aux";
img.dataset.feed = feedName;
img.src = feed.url + `?ts=${Date.now()}`;
tile.appendChild(img);
const label = document.createElement("div");
label.className = "overlay-label";
label.innerText = feed.label || feed.name;
tile.appendChild(label);
overlayTiles.appendChild(tile);
});
}
async function loadMetadata() {
try {
const resp = await fetch(metadataUrl);
if (!resp.ok) {
console.warn("Failed to load metadata");
return;
}
metadataCache = await resp.json();
const selection = populateRobotOptions(metadataCache);
if (selection) {
// Set pending selection so the backend switches to the default robot when WebSocket opens
pendingRobotSelection = {
value: buildSelectionValue(selection.robot, selection.scene),
robot: selection.robot,
scene: selection.scene
};
updateRobotUI(selection.robot, selection.scene);
refreshOverlayTiles();
}
const novaInfo = metadataCache && metadataCache.nova_api;
novaPreconfigured = Boolean(novaInfo && novaInfo.preconfigured);
setNovaToggleState(!!(novaInfo && novaInfo.preconfigured), !!(novaInfo && novaInfo.preconfigured));
maybeAutoEnableNova();
if (ws && ws.readyState === WebSocket.OPEN && pendingRobotSelection) {
send('switch_robot', {
robot: pendingRobotSelection.robot,
scene: pendingRobotSelection.scene
});
}
} catch (error) {
console.warn("Metadata fetch error:", error);
}
}
async function loadEnvData() {
try {
const resp = await fetch(envUrl);
if (!resp.ok) {
console.warn("Failed to load env data, status:", resp.status);
return;
}
envCache = await resp.json();
// Store home_pose if available
if (envCache && envCache.home_pose) {
currentHomePose = envCache.home_pose;
}
} catch (error) {
console.error("Env fetch error:", error);
}
}
function connect() {
ws = new WebSocket(WS_URL);
loadMetadata();
loadEnvData();
ws.onopen = () => {
// Send client identification immediately upon connection
send('client_identity', {
client_id: 'ui',
name: 'UI Client'
});
markConnectedState();
refreshVideoStreams();
refreshOverlayTiles();
novaAutoEnableRequested = false;
maybeAutoEnableNova();
if (reconnectTimer) {
clearInterval(reconnectTimer);
reconnectTimer = null;
}
if (pendingRobotSelection) {
send('switch_robot', {
robot: pendingRobotSelection.robot,
scene: pendingRobotSelection.scene
});
}
};
ws.onclose = () => {
enterConnectingState();
// Auto-reconnect
if (!reconnectTimer) {
reconnectTimer = setInterval(() => {
if (ws.readyState === WebSocket.CLOSED) {
connect();
}
}, 2000);
}
};
ws.onerror = (err) => {
console.error('WebSocket error:', err);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'state') {
const data = msg.data || {};
if (data.camera_event) {
handleCameraEvent(data.camera_event);
return;
}
// Check if robot or scene changed in state stream
if (data.robot && data.robot !== currentRobot) {
currentRobot = data.robot;
updateRobotUI(data.robot, data.scene);
// Fetch new environment info only when robot actually changes
fetch('/nova-sim/api/v1/env')
.then(r => r.json())
.then(envData => {
envCache = envData;
has_gripper = envData.has_gripper || false;
control_mode = envData.control_mode || 'ik';
if (envData.home_pose) {
currentHomePose = envData.home_pose;
}
// Update overlay tiles when robot changes
refreshOverlayTiles();
});
}
if (data.scene && data.scene !== currentScene) {
currentScene = data.scene;
// Update overlay tiles when scene changes (may have different camera feeds)
fetch('/nova-sim/api/v1/env')
.then(r => r.json())
.then(envData => {
envCache = envData;
refreshOverlayTiles();
});
}
// Handle both new and legacy client status formats
if (data.connected_clients !== undefined) {
const clients = data.connected_clients || [];
const unidentifiedClients = data.unidentified_clients || [];
updateConnectedClients(clients, unidentifiedClients);
} else if (typeof data.trainer_connected === 'boolean') {
// Legacy format
updateTrainerStatus(data.trainer_connected);
}
if (currentRobot === 'ur5' || currentRobot === 'ur5_t_push') {
// UR5 state - Access observation data first
const obs = data.observation || {};
// Update end effector position
if (obs.end_effector) {
const ee = obs.end_effector;
document.getElementById('ee_pos').innerText =
ee.x.toFixed(2) + ', ' + ee.y.toFixed(2) + ', ' + ee.z.toFixed(2);
}
// EE Orientation - convert quaternion to euler for display
if (obs.ee_orientation) {
const q = obs.ee_orientation;
const euler = quatToEuler(q.w, q.x, q.y, q.z);
document.getElementById('ee_ori').innerText =
euler[0].toFixed(2) + ', ' + euler[1].toFixed(2) + ', ' + euler[2].toFixed(2);
}
// Show/hide gripper UI based on has_gripper
const gripperStateDisplay = document.getElementById('gripper_state_display');
const gripperControls = document.getElementById('gripper_controls');
if (data.has_gripper) {
gripperStateDisplay.style.display = '';
gripperControls.style.display = '';
// Gripper: 0=open, 255=closed (Robotiq 2F-85)
if (obs.gripper !== undefined) {
document.getElementById('gripper_val').innerText =
((255 - obs.gripper) / 255 * 100).toFixed(0) + '% open';
}
} else {
gripperStateDisplay.style.display = 'none';
gripperControls.style.display = 'none';
}
document.getElementById('arm_step_val').innerText = data.steps;
const rewardEl = document.getElementById('arm_reward');
if (data.reward === null || data.reward === undefined) {
rewardEl.innerText = '-';
} else {
rewardEl.innerText = data.reward.toFixed(3);
}
// Update joint position display (actual positions)
if (obs.joint_positions) {
const jp = obs.joint_positions;
currentJointPositions = jp; // Store for homing
const jointPosDisplay = document.getElementById('joint_pos_display');
if (jointPosDisplay) {
jointPosDisplay.innerText = jp.map(j => j.toFixed(2)).join(', ');
}
// Update jog button displays with actual joint positions
for (let i = 0; i < 6; i++) {
const el = document.getElementById('joint_' + i + '_val');
if (el) {
el.innerText = jp[i].toFixed(2);
}
}
}
// Update cartesian position displays with actual EE position
if (obs.end_effector) {
const ee = obs.end_effector;
const posXEl = document.getElementById('pos_x_val');
const posYEl = document.getElementById('pos_y_val');
const posZEl = document.getElementById('pos_z_val');
if (posXEl) posXEl.innerText = ee.x.toFixed(3);
if (posYEl) posYEl.innerText = ee.y.toFixed(3);
if (posZEl) posZEl.innerText = ee.z.toFixed(3);
}
// Update rotation displays with actual EE orientation
if (obs.ee_orientation) {
const q = obs.ee_orientation;
const euler = quatToEuler(q.w, q.x, q.y, q.z);
document.getElementById('rot_x_val').innerText = euler[0].toFixed(2);
document.getElementById('rot_y_val').innerText = euler[1].toFixed(2);
document.getElementById('rot_z_val').innerText = euler[2].toFixed(2);
}
// Update control mode display
if (data.control_mode) {
document.getElementById('control_mode_display').innerText =
data.control_mode === 'ik' ? 'IK' : 'Joint';
// Sync UI if mode changed externally
if (currentControlMode !== data.control_mode) {
setControlMode(data.control_mode);
}
}
// Update Nova API controller status (only when values change)
if (data.nova_api) {
const novaApi = data.nova_api;
// Check if any Nova API state has changed
const novaStateChanged =
prevNovaState.available !== novaApi.available ||
prevNovaState.enabled !== novaApi.enabled ||
prevNovaState.connected !== novaApi.connected ||
prevNovaState.state_streaming !== novaApi.state_streaming ||
prevNovaState.ik !== novaApi.ik;
if (novaStateChanged) {
const stateController = document.getElementById('nova_state_controller');
const ikController = document.getElementById('nova_ik_controller');
const badge = document.getElementById('nova_connection_badge');
const badgeTitle = document.getElementById('nova_badge_title');
const modeText = document.getElementById('nova_mode_text');
// Check if this is the first update (all prev values are null)
const isFirstUpdate = prevNovaState.available === null && prevNovaState.enabled === null;
// Only update toggle state if available or enabled changed
if (prevNovaState.available !== novaApi.available || prevNovaState.enabled !== novaApi.enabled) {
setNovaToggleState(novaApi.available, novaApi.enabled);
}
// Track Nova state streaming status
novaStateStreaming = novaApi.state_streaming || false;
// Detect Nova connection errors and show toast notifications
const currentNovaConnected = novaApi.connected;
if (prevNovaConnected !== null && prevNovaConnected !== currentNovaConnected) {
if (!currentNovaConnected && novaApi.enabled) {
// Connection was lost or failed to connect while Nova is enabled
showClientNotification({
message: 'Nova API connection lost. Check that Nova is running and accessible.',
status: 'error'
});
} else if (currentNovaConnected && novaApi.enabled) {
// Connection was restored
showClientNotification({
message: 'Nova API connection established.',
status: 'info'
});
}
}
// Also check for enabled but not connected state (initial error)
if (prevNovaConnected === null && novaApi.enabled && !currentNovaConnected) {
showClientNotification({
message: 'Nova API is enabled but not connected. Waiting for Nova to be ready...',
status: 'warning'
});
}
prevNovaConnected = currentNovaConnected;
// Update badge visibility and classes
if (badge) {
// Always show the badge for arm robots so credentials can be updated
badge.style.display = 'block';
if (isFirstUpdate || prevNovaState.enabled !== novaApi.enabled) {
const isEnabled = Boolean(novaApi.enabled);
badge.classList.toggle('connected', isEnabled);
badge.classList.toggle('disconnected', !isEnabled);
}
}
// Update controller indicators
const connectedColor = novaApi.connected ? 'var(--wb-success)' : 'var(--wb-highlight)';
if (stateController && (prevNovaState.state_streaming !== novaApi.state_streaming || prevNovaState.connected !== novaApi.connected)) {
stateController.innerText = novaApi.state_streaming ? 'Nova API' : 'Internal';
stateController.style.color = connectedColor;
}
if (ikController && (prevNovaState.ik !== novaApi.ik || prevNovaState.connected !== novaApi.connected)) {
ikController.innerText = novaApi.ik ? 'Nova API' : 'Internal';
ikController.style.color = novaApi.ik && novaApi.connected ? 'var(--wb-success)' : 'var(--wb-highlight)';
}
// Update badge title
if (badgeTitle && (isFirstUpdate || prevNovaState.enabled !== novaApi.enabled || prevNovaState.connected !== novaApi.connected)) {
if (!novaApi.available) {
badgeTitle.innerText = '🌐 Nova Not Configured';
} else if (novaApi.enabled) {
badgeTitle.innerText = novaApi.connected ? '🌐 Nova Connected' : '🌐 Nova Powering Up';
} else {
badgeTitle.innerText = '🌐 Nova Disabled';
}
}
// Update mode text
if (modeText) {
let newModeText = '';
if (novaApi.enabled) {
// Check what Nova features are active
const hasStateStream = novaApi.state_streaming && novaApi.connected;
const hasIK = novaApi.ik;
if (hasStateStream && hasIK) {
newModeText = 'Hybrid Mode';
} else if (hasStateStream) {
newModeText = 'Digital Twin Mode';
} else if (hasIK) {
newModeText = novaApi.connected ? 'Nova IK Mode' : 'Nova IK Mode (connecting...)';
} else if (novaApi.connected) {
newModeText = 'Nova Connected (idle)';
} else {
newModeText = 'Awaiting connection';
}
} else {
newModeText = 'Internal control';
}
if (modeText.innerText !== newModeText) {
modeText.innerText = newModeText;
}
}
// Store current state for next comparison
prevNovaState = {
available: novaApi.available,
enabled: novaApi.enabled,
connected: novaApi.connected,
state_streaming: novaApi.state_streaming,
ik: novaApi.ik
};
}
}
// Update teleop command display - only show non-zero values
const armTeleop = data.teleop_action;
const armTeleopDisplayEl = document.getElementById('arm_teleop_display');
if (armTeleop && armTeleopDisplayEl) {
const parts = [];
// Check all possible teleop fields
const fields = {
'vx': armTeleop.vx,
'vy': armTeleop.vy,
'vz': armTeleop.vz,
'vyaw': armTeleop.vyaw,
'vrx': armTeleop.vrx,
'vry': armTeleop.vry,
'vrz': armTeleop.vrz,
'j1': armTeleop.j1,
'j2': armTeleop.j2,
'j3': armTeleop.j3,
'j4': armTeleop.j4,
'j5': armTeleop.j5,
'j6': armTeleop.j6,
'g': armTeleop.gripper
};
for (const [key, value] of Object.entries(fields)) {
const numVal = Number(value ?? 0);
if (numVal !== 0) {
if (key === 'g') {
parts.push(`${key}=${numVal.toFixed(0)}`);
} else {
parts.push(`${key}=${numVal.toFixed(2)}`);
}
}
}
armTeleopDisplayEl.innerText = parts.length > 0 ? parts.join(', ') : '-';
}
} else {
// Locomotion state
const locoObs = data.observation || {};
// Update position display
const locoPos = document.getElementById('loco_pos');
if (locoObs.position) {
const p = locoObs.position;
locoPos.innerText = `${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}`;
}
// Update orientation display (convert quaternion to euler)
const locoOri = document.getElementById('loco_ori');
if (locoObs.orientation) {
const q = locoObs.orientation;
const euler = quatToEuler(q.w, q.x, q.y, q.z);
locoOri.innerText = `${euler[0].toFixed(2)}, ${euler[1].toFixed(2)}, ${euler[2].toFixed(2)}`;
}
// Update teleop command display - only show non-zero values
const locoTeleop = data.teleop_action || {};
const locoTeleopDisplayEl = document.getElementById('loco_teleop_display');
if (locoTeleopDisplayEl) {
const parts = [];
const fields = {
'vx': locoTeleop.vx,
'vy': locoTeleop.vy,
'vyaw': locoTeleop.vyaw
};
for (const [key, value] of Object.entries(fields)) {
const numVal = Number(value ?? 0);
if (numVal !== 0) {
parts.push(`${key}=${numVal.toFixed(2)}`);
}
}
locoTeleopDisplayEl.innerText = parts.length > 0 ? parts.join(', ') : '-';
}
stepVal.innerText = data.steps;
}
} else if (msg.type === 'connected_clients') {
const payload = msg.data || {};
const clients = payload.clients || [];
const unidentifiedClients = payload.unidentified_clients || [];
updateConnectedClients(clients, unidentifiedClients);
} else if (msg.type === 'trainer_status') {
// Legacy support
const payload = msg.data || {};
if (typeof payload.connected === 'boolean') {
updateTrainerStatus(payload.connected);
}
} else if (msg.type === 'client_notification' || msg.type === 'trainer_notification') {
showClientNotification(msg.data);
}
} catch (e) {
console.error('Error parsing message:', e);
}
};
}
function send(type, data = {}) {
if (ws && ws.readyState === WebSocket.OPEN) {
const msg = { type, data };
ws.send(JSON.stringify(msg));
} else {
console.warn('Cannot send message, WebSocket not ready:', {
type,
hasWs: !!ws,
wsState: ws ? ws.readyState : 'no ws'
});
}
}
function requestEpisodeControl(action) {
if (!action) {
return;
}
send('episode_control', { action });
}
function showClientNotification(payload) {
if (!notificationList || !payload) {
return;
}
const entry = document.createElement('div');
entry.className = 'notification';
entry.style.opacity = '0.9';
const when = payload.timestamp ? new Date(payload.timestamp * 1000).toLocaleTimeString() : new Date().toLocaleTimeString();
const status = payload.status ? payload.status.toUpperCase() : 'INFO';
const message = payload.message || payload.text || payload.detail || 'Notification';
entry.textContent = `[${when}] ${status}: ${message}`;
notificationList.insertBefore(entry, notificationList.firstChild);
setTimeout(() => {
if (entry.parentNode) {
entry.parentNode.removeChild(entry);
}
}, NOTIFICATION_DURATION_MS);
while (notificationList.childElementCount > 5) {
notificationList.removeChild(notificationList.lastChild);
}
}
// Convert quaternion to euler angles (XYZ convention)
function quatToEuler(w, x, y, z) {
// Roll (x-axis rotation)
const sinr_cosp = 2 * (w * x + y * z);
const cosr_cosp = 1 - 2 * (x * x + y * y);
const roll = Math.atan2(sinr_cosp, cosr_cosp);
// Pitch (y-axis rotation)
const sinp = 2 * (w * y - z * x);
let pitch;
if (Math.abs(sinp) >= 1) {
pitch = Math.sign(sinp) * Math.PI / 2;
} else {
pitch = Math.asin(sinp);
}
// Yaw (z-axis rotation)
const siny_cosp = 2 * (w * z + x * y);
const cosy_cosp = 1 - 2 * (y * y + z * z);
const yaw = Math.atan2(siny_cosp, cosy_cosp);
return [roll, pitch, yaw];
}
function updateHintBox(robotType) {
const hintBox = document.getElementById('hint_box');
if (!hintBox) return;
if (robotType === 'arm') {
hintBox.innerHTML = `
Drag: Rotate Camera<br>
Scroll: Zoom<br>
<strong>Keyboard:</strong><br>
W/A/S/D: XY jog<br>
R/F: Z nudge<br>
Enter: Move to Home
`;
} else {
hintBox.innerHTML = `
Drag: Rotate Camera<br>
Scroll: Zoom<br>
<strong>Keyboard:</strong><br>
W/S: Forward/Back<br>
A/D: Turn<br>
Q/E: Strafe
`;
}
}
function updateRobotUI(robot, scene = null) {
currentRobot = robot;
currentScene = scene;
robotTitle.innerText = robotTitles[robot] || robot;
robotInfo.innerText = robotInfoText[robot] || '';
if (sceneLabel) {
sceneLabel.innerText = humanizeScene(scene);
}
// Sync robot selector with active robot
const expectedValue = buildSelectionValue(robot, scene);
if (robotSelect.value !== expectedValue) {
robotSelect.value = expectedValue;
}
// Update camera follow label based on robot type
const camFollowLabel = document.getElementById('cam_follow_label');
if (camFollowLabel) {
if (robot === 'ur5' || robot === 'ur5_t_push') {
camFollowLabel.innerText = 'Camera Follow Object';
} else {
camFollowLabel.innerText = 'Camera Follow Robot';
}
}
// Show/hide target visibility control (only for push-T scene)
const targetVisibilityControl = document.getElementById('target_visibility_control');
if (targetVisibilityControl) {
if (robot === 'ur5_t_push') {
targetVisibilityControl.style.display = 'block';
} else {
targetVisibilityControl.style.display = 'none';
}
}
// Toggle controls based on robot type
if (robot === 'ur5' || robot === 'ur5_t_push') {
locomotionControls.classList.add('hidden');
armControls.classList.add('active');
document.getElementById('locomotion_state').style.display = 'none';
document.getElementById('arm_state').style.display = 'block';
document.getElementById('arm_hints').style.display = 'block';
document.getElementById('loco_hints').style.display = 'none';
// Update hint box for ARM robots
updateHintBox('arm');
// Load env data and show home button only if home_pose is available
loadEnvData().then(() => {
setupOverlayTiles();
// Show home button only if home_pose was loaded
const homeBtn = document.getElementById('homeBtn');
if (homeBtn) {
if (currentHomePose && currentHomePose.length > 0) {
homeBtn.style.display = 'block';
} else {
homeBtn.style.display = 'none';
console.warn('Home button hidden: no home_pose available');
}
}
});
} else {
locomotionControls.classList.remove('hidden');
armControls.classList.remove('active');
document.getElementById('locomotion_state').style.display = 'block';
document.getElementById('arm_state').style.display = 'none';
document.getElementById('arm_hints').style.display = 'none';
document.getElementById('loco_hints').style.display = 'block';
// Hide home button for non-UR5 robots
const homeBtn = document.getElementById('homeBtn');
if (homeBtn) homeBtn.style.display = 'none';
// Update hint box for locomotion robots
updateHintBox('locomotion');
}
maybeAutoEnableNova();
}
let panelCollapsed = false;
function togglePanel() {
panelCollapsed = !panelCollapsed;
const content = document.getElementById('panel_content');
const header = document.getElementById('panel_header');
const panel = document.getElementById('control_panel');
if (panelCollapsed) {
content.classList.add('collapsed');
panel.classList.add('collapsed');
header.classList.add('collapsed');
} else {
content.classList.remove('collapsed');
panel.classList.remove('collapsed');
header.classList.remove('collapsed');
}
}
function switchRobot() {
const selectionValue = robotSelect.value;
if (!selectionValue) {
return;
}
const parsed = parseSelection(selectionValue);
pendingRobotSelection = {
value: selectionValue,
robot: parsed.robot,
scene: parsed.scene
};
send('switch_robot', { robot: parsed.robot, scene: parsed.scene });
updateRobotUI(parsed.robot, parsed.scene);
}
const viewport = document.getElementById('viewport');
const stepVal = document.getElementById('step_val');
const camDist = document.getElementById('cam_dist');
const camDistVal = document.getElementById('cam_dist_val');
const armTeleopVx = document.getElementById('arm_teleop_vx');
const armTeleopVy = document.getElementById('arm_teleop_vy');
const armTeleopVz = document.getElementById('arm_teleop_vz');
let keysPressed = new Set();
function resetEnv() {
send('reset');
}
// Homing functionality for UR5
let homingInProgress = false;
function setHomeButtonState(inProgress) {
const homeBtn = document.getElementById('homeBtn');
if (!homeBtn) {
return;
}
homeBtn.disabled = inProgress;
homeBtn.textContent = inProgress ? '🏠 Homing...' : '🏠 Move to Home';
}
async function startHoming() {
if (currentRobot !== 'ur5' && currentRobot !== 'ur5_t_push') {
return;
}
// Check if we have necessary data
if (!currentHomePose) {
return;
}
if (homingInProgress) {
return;
}
homingInProgress = true;
setHomeButtonState(true);
const params = new URLSearchParams({
timeout_s: '30',
tolerance: '0.01',
poll_interval_s: '0.1'
});
try {
const response = await fetch(`${API_PREFIX}/homing?${params.toString()}`);
const payload = await response.json();
if (!response.ok) {
console.warn('Homing failed:', payload);
}
} catch (err) {
console.warn('Homing request failed:', err);
} finally {
homingInProgress = false;
setHomeButtonState(false);
}
}
function setCmd(vx, vy, vyaw) {
send('command', { vx, vy, vyaw });
}
function setCameraFollow() {
const follow = document.getElementById('cam_follow').checked;
send('camera_follow', { follow });
}
function setTargetVisibility() {
const visible = document.getElementById('target_visible').checked;
send('toggle_target_visibility', { visible });
}
// UR5 controls
let currentControlMode = 'ik';
function setControlMode(mode) {
currentControlMode = mode;
send('control_mode', { mode });
// Update UI
document.getElementById('mode_ik').classList.toggle('active', mode === 'ik');
document.getElementById('mode_joint').classList.toggle('active', mode === 'joint');
document.getElementById('ik_controls').classList.toggle('active', mode === 'ik');
document.getElementById('joint_controls').classList.toggle('active', mode === 'joint');
}
// Jogging velocities
let transVelocity = 50.0; // mm/s
let rotVelocity = 0.3; // rad/s
let jointVelocity = 0.5; // rad/s
function updateTransVelocity() {
transVelocity = parseFloat(document.getElementById('trans_velocity').value);
document.getElementById('trans_vel_val').innerText = transVelocity.toFixed(0);
teleopTranslationStep = transVelocity / 1000;
}
function updateRotVelocity() {
rotVelocity = parseFloat(document.getElementById('rot_velocity').value);
document.getElementById('rot_vel_val').innerText = rotVelocity.toFixed(1);
}
function updateJointVelocity() {
jointVelocity = parseFloat(document.getElementById('joint_velocity').value);
document.getElementById('joint_vel_val').innerText = jointVelocity.toFixed(1);
}
const activeJogButtons = new Map();
function _applyKeyboardJog(translation) {
if (keysPressed.has('KeyW')) translation.x += transVelocity / 1000.0;
if (keysPressed.has('KeyS')) translation.x -= transVelocity / 1000.0;
if (keysPressed.has('KeyA')) translation.y += transVelocity / 1000.0;
if (keysPressed.has('KeyD')) translation.y -= transVelocity / 1000.0;
if (keysPressed.has('KeyR')) translation.z += transVelocity / 1000.0;
if (keysPressed.has('KeyF')) translation.z -= transVelocity / 1000.0;
}
function _updateJogAction() {
const actionData = {};
const translation = { x: 0.0, y: 0.0, z: 0.0 };
const rotation = { x: 0.0, y: 0.0, z: 0.0 };
const joint = { 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0, 5: 0.0, 6: 0.0 };
for (const entry of activeJogButtons.values()) {
const sign = entry.direction === '+' ? 1 : -1;
if (entry.jogType === 'cartesian_translation') {
translation[entry.axisOrJoint] += sign * (transVelocity / 1000.0);
} else if (entry.jogType === 'cartesian_rotation') {
rotation[entry.axisOrJoint] += sign * rotVelocity;
} else if (entry.jogType === 'joint') {
const jointKey = Number(entry.axisOrJoint);
if (jointKey >= 1 && jointKey <= 6) {
joint[jointKey] += sign * jointVelocity;
}
}
}
_applyKeyboardJog(translation);
if (translation.x || translation.y || translation.z) {
if (translation.x) actionData.vx = translation.x;
if (translation.y) actionData.vy = translation.y;
if (translation.z) actionData.vz = translation.z;
}
if (rotation.x || rotation.y || rotation.z) {
if (rotation.x) actionData.vrx = rotation.x;
if (rotation.y) actionData.vry = rotation.y;
if (rotation.z) actionData.vrz = rotation.z;
}
for (let j = 1; j <= 6; j += 1) {
if (joint[j]) {
actionData['j' + j] = joint[j];
}
}
if (Object.keys(actionData).length) {
send('action', actionData);
} else {
send('action', {}); // Send zero velocities
}
}
function startJog(jogType, axisOrJoint, direction) {
const key = `${jogType}:${axisOrJoint}:${direction}`;
activeJogButtons.set(key, { jogType, axisOrJoint, direction });
_updateJogAction();
}
function stopJog(jogType, axisOrJoint, direction) {
const key = `${jogType}:${axisOrJoint}:${direction}`;
activeJogButtons.delete(key);
_updateJogAction();
}
function setGripper(action) {
send('gripper', { action });
}
function updateCmdFromKeys() {
let vx = 0, vy = 0, vyaw = 0;
if (keysPressed.has('KeyW') || keysPressed.has('ArrowUp')) vx = 0.8;
if (keysPressed.has('KeyS') || keysPressed.has('ArrowDown')) vx = -0.5;
if (keysPressed.has('KeyA')) vyaw = 1.2;
if (keysPressed.has('KeyD')) vyaw = -1.2;
if (keysPressed.has('KeyQ')) vy = 0.4;
if (keysPressed.has('KeyE')) vy = -0.4;
if (keysPressed.has('ArrowLeft')) vyaw = 1.2;
if (keysPressed.has('ArrowRight')) vyaw = -1.2;
setCmd(vx, vy, vyaw);
}
function getActiveSelection() {
return parseSelection(robotSelect.value);
}
function isArmRobot() {
const active = getActiveSelection();
return armRobotTypes.has(active.robot);
}
function shouldCaptureKey(code) {
const active = getActiveSelection();
return armRobotTypes.has(active.robot) ? armTeleopKeys.has(code) : locomotionKeys.has(code);
}
function hasActiveTeleopKeys() {
const keySet = isArmRobot() ? armTeleopKeys : locomotionKeys;
for (const key of keySet) {
if (keysPressed.has(key)) {
return true;
}
}
return false;
}
function startTeleopRepeat() {
if (teleopRepeatTimer) {
return;
}
teleopRepeatTimer = setInterval(() => updateArmTeleopFromKeys(true), TELEOP_REPEAT_INTERVAL_MS);
}
function stopTeleopRepeat() {
if (!teleopRepeatTimer) {
return;
}
clearInterval(teleopRepeatTimer);
teleopRepeatTimer = null;
}
function updateArmTeleopFromKeys(force = false) {
let dx = 0, dy = 0, dz = 0;
if (keysPressed.has('KeyW')) dx += teleopTranslationStep;
if (keysPressed.has('KeyS')) dx -= teleopTranslationStep;
if (keysPressed.has('KeyA')) dy += teleopTranslationStep;
if (keysPressed.has('KeyD')) dy -= teleopTranslationStep;
if (keysPressed.has('KeyR')) dz += teleopVerticalStep;
if (keysPressed.has('KeyF')) dz -= teleopVerticalStep;
const unchanged =
Math.abs(dx - lastTeleopCommand.dx) < 1e-6 &&
Math.abs(dy - lastTeleopCommand.dy) < 1e-6 &&
Math.abs(dz - lastTeleopCommand.dz) < 1e-6;
if (unchanged && !force) {
return;
}
lastTeleopCommand = { dx, dy, dz };
send('teleop_action', { dx, dy, dz });
}
function handleArmKeyDown(code) {
if (!armJogKeyMap[code]) {
return;
}
_updateJogAction();
}
function handleArmKeyUp(code) {
if (!armJogKeyMap[code]) {
return;
}
_updateJogAction();
}
function handleKeyStateChange() {
if (isArmRobot()) {
return;
}
const active = hasActiveTeleopKeys();
if (active) {
startTeleopRepeat();
} else {
stopTeleopRepeat();
}
updateCmdFromKeys();
}
window.addEventListener('keydown', (e) => {
if (e.code === 'Enter') {
e.preventDefault();
// For ARM robots, trigger homing; for locomotion robots, terminate episode
if (isArmRobot()) {
startHoming();
} else {
requestEpisodeControl('terminate');
}
return;
}
if (shouldCaptureKey(e.code)) {
e.preventDefault();
const alreadyPressed = keysPressed.has(e.code);
keysPressed.add(e.code);
if (isArmRobot()) {
if (!alreadyPressed) {
handleArmKeyDown(e.code);
}
} else {
handleKeyStateChange();
}
}
});
window.addEventListener('keyup', (e) => {
if (e.code === 'Enter') {
// For ARM robots, stop homing on Enter key release
if (isArmRobot()) {
stopHoming();
}
return;
}
if (keysPressed.delete(e.code)) {
if (isArmRobot()) {
handleArmKeyUp(e.code);
} else {
handleKeyStateChange();
}
}
});
camDist.oninput = () => {
camDistVal.innerText = parseFloat(camDist.value).toFixed(1);
send('camera', { action: 'set_distance', distance: parseFloat(camDist.value) });
};
let isDragging = false;
let lastX, lastY;
viewport.oncontextmenu = (e) => e.preventDefault();
// Mouse controls
viewport.onmousedown = (e) => {
isDragging = true;
lastX = e.clientX;
lastY = e.clientY;
};
window.addEventListener('mouseup', () => {
isDragging = false;
});
window.onmousemove = (e) => {
if (isDragging) {
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
send('camera', { action: 'rotate', dx, dy });
}
};
viewport.onwheel = (e) => {
e.preventDefault();
send('camera', { action: 'zoom', dz: e.deltaY });
};
// Touch controls for camera rotation and pinch-to-zoom
let touchStartX, touchStartY;
let lastPinchDist = null;
function getTouchDistance(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
viewport.addEventListener('touchstart', (e) => {
if (e.touches.length === 1) {
// Single touch - rotation
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
lastPinchDist = null;
} else if (e.touches.length === 2) {
// Two touches - pinch zoom
lastPinchDist = getTouchDistance(e.touches);
}
}, { passive: true });
viewport.addEventListener('touchmove', (e) => {
e.preventDefault();
if (e.touches.length === 1 && lastPinchDist === null) {
// Single touch drag - rotate camera
const dx = e.touches[0].clientX - touchStartX;
const dy = e.touches[0].clientY - touchStartY;
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
send('camera', { action: 'rotate', dx, dy });
} else if (e.touches.length === 2 && lastPinchDist !== null) {
// Pinch zoom
const dist = getTouchDistance(e.touches);
const delta = lastPinchDist - dist;
lastPinchDist = dist;
// Scale delta for smoother zoom
send('camera', { action: 'zoom', dz: delta * 3 });
}
}, { passive: false });
viewport.addEventListener('touchend', (e) => {
if (e.touches.length < 2) {
lastPinchDist = null;
}
if (e.touches.length === 1) {
// Reset for single finger after pinch
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
}
}, { passive: true });
// Connect on load
enterConnectingState();
connect();
</script>
</body>
</html>