| <!DOCTYPE html> |
| <html> |
|
|
| <head> |
| <title>Nova Sim - Wandelbots Robot Simulator</title> |
| <style> |
| |
| |
| |
| |
| |
| |
| :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-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-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; |
| } |
| |
| |
| .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; |
| } |
| |
| |
| .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> |
|
|
| |
| <div class="state-panel" id="state_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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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; |
| let currentHomePose = null; |
| let currentJointPositions = null; |
| let prevNovaConnected = null; |
| |
| 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; |
| } |
| |
| |
| 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`; |
| |
| |
| 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"; |
| } |
| } |
| |
| |
| function updateTrainerStatus(connected) { |
| |
| 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; |
| 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; |
| } |
| |
| 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) { |
| |
| 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(); |
| |
| 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_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(); |
| |
| 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; |
| } |
| |
| |
| if (data.robot && data.robot !== currentRobot) { |
| currentRobot = data.robot; |
| updateRobotUI(data.robot, data.scene); |
| |
| 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; |
| } |
| |
| refreshOverlayTiles(); |
| }); |
| } |
| if (data.scene && data.scene !== currentScene) { |
| currentScene = data.scene; |
| |
| fetch('/nova-sim/api/v1/env') |
| .then(r => r.json()) |
| .then(envData => { |
| envCache = envData; |
| refreshOverlayTiles(); |
| }); |
| } |
| |
| |
| 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') { |
| |
| updateTrainerStatus(data.trainer_connected); |
| } |
| |
| if (currentRobot === 'ur5' || currentRobot === 'ur5_t_push') { |
| |
| const obs = data.observation || {}; |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| const gripperStateDisplay = document.getElementById('gripper_state_display'); |
| const gripperControls = document.getElementById('gripper_controls'); |
| if (data.has_gripper) { |
| gripperStateDisplay.style.display = ''; |
| gripperControls.style.display = ''; |
| |
| 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); |
| } |
| |
| |
| if (obs.joint_positions) { |
| const jp = obs.joint_positions; |
| currentJointPositions = jp; |
| const jointPosDisplay = document.getElementById('joint_pos_display'); |
| if (jointPosDisplay) { |
| jointPosDisplay.innerText = jp.map(j => j.toFixed(2)).join(', '); |
| } |
| |
| |
| for (let i = 0; i < 6; i++) { |
| const el = document.getElementById('joint_' + i + '_val'); |
| if (el) { |
| el.innerText = jp[i].toFixed(2); |
| } |
| } |
| |
| } |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| |
| if (data.control_mode) { |
| document.getElementById('control_mode_display').innerText = |
| data.control_mode === 'ik' ? 'IK' : 'Joint'; |
| |
| if (currentControlMode !== data.control_mode) { |
| setControlMode(data.control_mode); |
| } |
| } |
| |
| |
| if (data.nova_api) { |
| const novaApi = data.nova_api; |
| |
| |
| 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'); |
| |
| |
| const isFirstUpdate = prevNovaState.available === null && prevNovaState.enabled === null; |
| |
| |
| if (prevNovaState.available !== novaApi.available || prevNovaState.enabled !== novaApi.enabled) { |
| setNovaToggleState(novaApi.available, novaApi.enabled); |
| } |
| |
| |
| novaStateStreaming = novaApi.state_streaming || false; |
| |
| |
| const currentNovaConnected = novaApi.connected; |
| if (prevNovaConnected !== null && prevNovaConnected !== currentNovaConnected) { |
| if (!currentNovaConnected && novaApi.enabled) { |
| |
| showClientNotification({ |
| message: 'Nova API connection lost. Check that Nova is running and accessible.', |
| status: 'error' |
| }); |
| } else if (currentNovaConnected && novaApi.enabled) { |
| |
| showClientNotification({ |
| message: 'Nova API connection established.', |
| status: 'info' |
| }); |
| } |
| } |
| |
| 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; |
| |
| |
| if (badge) { |
| |
| 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); |
| } |
| } |
| |
| |
| 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)'; |
| } |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| if (modeText) { |
| let newModeText = ''; |
| if (novaApi.enabled) { |
| |
| 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; |
| } |
| } |
| |
| |
| prevNovaState = { |
| available: novaApi.available, |
| enabled: novaApi.enabled, |
| connected: novaApi.connected, |
| state_streaming: novaApi.state_streaming, |
| ik: novaApi.ik |
| }; |
| } |
| } |
| |
| |
| const armTeleop = data.teleop_action; |
| const armTeleopDisplayEl = document.getElementById('arm_teleop_display'); |
| if (armTeleop && armTeleopDisplayEl) { |
| const parts = []; |
| |
| 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 { |
| |
| const locoObs = data.observation || {}; |
| |
| |
| 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)}`; |
| } |
| |
| |
| 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)}`; |
| } |
| |
| |
| 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') { |
| |
| 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); |
| } |
| } |
| |
| |
| function quatToEuler(w, x, y, z) { |
| |
| 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); |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
| } |
| |
| const expectedValue = buildSelectionValue(robot, scene); |
| if (robotSelect.value !== expectedValue) { |
| robotSelect.value = expectedValue; |
| } |
| |
| |
| 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'; |
| } |
| } |
| |
| |
| const targetVisibilityControl = document.getElementById('target_visibility_control'); |
| if (targetVisibilityControl) { |
| if (robot === 'ur5_t_push') { |
| targetVisibilityControl.style.display = 'block'; |
| } else { |
| targetVisibilityControl.style.display = 'none'; |
| } |
| } |
| |
| |
| 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'; |
| |
| updateHintBox('arm'); |
| |
| loadEnvData().then(() => { |
| setupOverlayTiles(); |
| |
| 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'; |
| |
| const homeBtn = document.getElementById('homeBtn'); |
| if (homeBtn) homeBtn.style.display = 'none'; |
| |
| 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'); |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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 }); |
| } |
| |
| |
| let currentControlMode = 'ik'; |
| |
| function setControlMode(mode) { |
| currentControlMode = mode; |
| send('control_mode', { mode }); |
| |
| |
| 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'); |
| } |
| |
| |
| let transVelocity = 50.0; |
| let rotVelocity = 0.3; |
| let jointVelocity = 0.5; |
| |
| 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', {}); |
| } |
| } |
| |
| 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(); |
| |
| 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') { |
| |
| 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(); |
| |
| |
| 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 }); |
| }; |
| |
| |
| 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) { |
| |
| touchStartX = e.touches[0].clientX; |
| touchStartY = e.touches[0].clientY; |
| lastPinchDist = null; |
| } else if (e.touches.length === 2) { |
| |
| lastPinchDist = getTouchDistance(e.touches); |
| } |
| }, { passive: true }); |
| |
| viewport.addEventListener('touchmove', (e) => { |
| e.preventDefault(); |
| if (e.touches.length === 1 && lastPinchDist === null) { |
| |
| 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) { |
| |
| const dist = getTouchDistance(e.touches); |
| const delta = lastPinchDist - dist; |
| lastPinchDist = dist; |
| |
| 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) { |
| |
| touchStartX = e.touches[0].clientX; |
| touchStartY = e.touches[0].clientY; |
| } |
| }, { passive: true }); |
| |
| |
| enterConnectingState(); |
| connect(); |
| </script> |
| </body> |
|
|
| </html> |
|
|