diff --git a/src/components/BodyTeleop/ControlsBar.jsx b/src/components/BodyTeleop/ControlsBar.jsx
new file mode 100644
index 00000000..79d2c267
--- /dev/null
+++ b/src/components/BodyTeleop/ControlsBar.jsx
@@ -0,0 +1,92 @@
+import React, { useCallback } from 'react';
+import PhotoCamera from '@material-ui/icons/PhotoCamera';
+
+const QUICK_SOUNDS = [
+ { key: 'engage', label: 'Engage', icon: '😊' },
+ { key: 'disengage', label: 'Disengage', icon: '😢' },
+ { key: 'prompt', label: 'Prompt', icon: '⚠️' },
+ { key: 'warningImmediate', label: 'Warning', icon: '❗' },
+];
+
+const CAMERAS = [
+ { key: 'wideRoad', label: 'road', num: '1' },
+ { key: 'driver', label: 'driver', num: '2' },
+];
+
+const btnBase = `h-8 px-2.5 rounded-lg text-[10px] font-bold tracking-[0.2px] uppercase flex items-center justify-center min-w-[32px] cursor-pointer select-none hover:text-white hover:bg-white/20 bg-glass`;
+const btnInactive = `${btnBase} bg-white/10 text-white/60`;
+const btnActive = `${btnBase} bg-white/30 text-white`;
+
+const controlsGroupBase = 'absolute bottom-4 left-4 z-10 flex flex-row items-stretch gap-2.5 rounded-[14px] p-2 bg-glass-dark';
+const controlsGroupPortrait = 'relative bottom-auto left-auto transform-none self-stretch rounded-none shrink-0 justify-between gap-1.5';
+
+const ControlsBar = ({
+ connection, activeCamera, onSwitchCamera,
+ gamepadConnected, videoRef, isLandscape,
+}) => {
+ const handlePlaySound = useCallback((sound) => {
+ connection?.playSound(sound).catch((err) => {
+ console.error('Failed to play body sound:', err);
+ });
+ }, [connection]);
+
+ const handleScreenshot = useCallback(() => {
+ const video = videoRef?.current;
+ if (!video || !video.videoWidth) return;
+ const canvas = document.createElement('canvas');
+ canvas.width = video.videoWidth;
+ canvas.height = video.videoHeight;
+ canvas.getContext('2d').drawImage(video, 0, 0);
+ const link = document.createElement('a');
+ link.download = `screenshot_${activeCamera}_${Date.now()}.png`;
+ link.href = canvas.toDataURL('image/png');
+ link.click();
+ }, [videoRef, activeCamera]);
+
+ return (
+
+ {!gamepadConnected && (
+
+
+ {CAMERAS.map((cam) => (
+
onSwitchCamera(cam.key)}
+ >
+ {isLandscape ? cam.label : cam.num}
+
+ ))}
+
+
Camera
+
+ )}
+
+
+ {QUICK_SOUNDS.map((sound) => (
+
handlePlaySound(sound.key)}
+ >
+ {isLandscape ? sound.label : sound.icon}
+
+ ))}
+
+
Sounds
+
+
+
+ );
+};
+
+export default ControlsBar;
diff --git a/src/components/BodyTeleop/Joystick.jsx b/src/components/BodyTeleop/Joystick.jsx
new file mode 100644
index 00000000..e6e402cf
--- /dev/null
+++ b/src/components/BodyTeleop/Joystick.jsx
@@ -0,0 +1,330 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+
+
+const TriggerGroup = ({ bumperActive, bumperLabel, bumperKey, cameraActive, triggerValue, triggerColor, triggerKey, directionLabel }) => {
+ const activeStyle = cameraActive ? { background: 'rgba(59,130,246,0.35)', borderColor: 'rgba(59,130,246,0.5)' } : undefined;
+
+ return (
+
+
+ {bumperLabel}
+
+
+
+ {bumperKey}
+
+
+
+
+ {directionLabel}
+
+
+ );
+}
+
+const ControllerOverlay = ({ gamepadSteering, gamepadGas, gamepadBrake, gamepadLB, gamepadRB, activeCamera }) => {
+ const thumbLeft = 50 + gamepadSteering * 34;
+
+ return (
+
+
+
+
+
L Stick — Steering
+
+
+
{'\u25C0'}
+
{'\u25B6'}
+
+
+
+
+
+
+ );
+}
+
+const TouchJoystick = ({ className, thumbPos, joystickAreaRef, onTouchStart, onTouchMove, onTouchEnd, onMouseDown }) => {
+ const thumbRange = 45;
+ const thumbLeft = thumbPos ? `${50 + thumbPos.x * thumbRange}%` : '50%';
+ const thumbTop = thumbPos ? `${50 + thumbPos.y * thumbRange}%` : '50%';
+
+ return (
+ e.preventDefault()}
+ >
+
+
+
+
+
+ );
+}
+
+const Joystick = ({
+ connection, activeCamera, className,
+ onGamepadChange, onSwitchCamera, gamepadConnected,
+}) => {
+ const [thumbPos, setThumbPos] = useState(null);
+ const [, setKeys] = useState({ w: false, a: false, s: false, d: false });
+ const [gamepadState, setGamepadState] = useState({
+ steering: 0, gas: 0, brake: 0, lb: false, rb: false,
+ });
+
+ const joystickAreaRef = useRef(null);
+ const touchIdRef = useRef(null);
+ const mouseDraggingRef = useRef(false);
+ const prevBumpersRef = useRef({ lb: false, rb: false });
+ const prevGamepadRef = useRef({ steering: 0, gas: 0, brake: 0, lb: false, rb: false });
+ const prevThumbRef = useRef(null);
+ const gamepadFrameRef = useRef(null);
+ const triggerActivatedRef = useRef({ lt: false, rt: false });
+
+ const isRearCamera = activeCamera === 'wideRoad';
+
+ const setFlippedJoystick = useCallback((x, y) => {
+ const flip = isRearCamera ? -1 : 1;
+ connection?.setJoystick(flip * x, y);
+ }, [connection, isRearCamera]);
+
+ // Touch joystick
+ const applyJoystick = useCallback((clientX, clientY) => {
+ const area = joystickAreaRef.current;
+ if (!area) return;
+ const rect = area.getBoundingClientRect();
+ const halfW = rect.width / 2;
+ const halfH = rect.height / 2;
+ let dx = (clientX - rect.left - halfW) / halfW;
+ let dy = (clientY - rect.top - halfH) / halfH;
+ dx = Math.max(-1, Math.min(1, dx));
+ dy = Math.max(-1, Math.min(1, dy));
+ setThumbPos({ x: dx, y: dy });
+ const cx = Math.sign(dx) * Math.max(Math.abs(dx), 0.20);
+ const cy = Math.sign(dy) * Math.max(Math.abs(dy), 0.20);
+ setFlippedJoystick(cy, -cx);
+ }, [setFlippedJoystick]);
+
+ const resetJoystick = useCallback(() => {
+ setThumbPos(null);
+ connection?.setJoystick(0, 0);
+ }, [connection]);
+
+ const handleTouchStart = useCallback((e) => {
+ e.preventDefault();
+ if (touchIdRef.current !== null) return;
+ const t = e.changedTouches[0];
+ touchIdRef.current = t.identifier;
+ applyJoystick(t.clientX, t.clientY);
+ }, [applyJoystick]);
+
+ const handleTouchMove = useCallback((e) => {
+ e.preventDefault();
+ for (let i = 0; i < e.changedTouches.length; i++) {
+ const t = e.changedTouches[i];
+ if (t.identifier === touchIdRef.current) applyJoystick(t.clientX, t.clientY);
+ }
+ }, [applyJoystick]);
+
+ const handleTouchEnd = useCallback((e) => {
+ e.preventDefault();
+ for (let i = 0; i < e.changedTouches.length; i++) {
+ if (e.changedTouches[i].identifier === touchIdRef.current) {
+ touchIdRef.current = null;
+ resetJoystick();
+ }
+ }
+ }, [resetJoystick]);
+
+ const handleMouseMove = useCallback((e) => {
+ if (mouseDraggingRef.current) applyJoystick(e.clientX, e.clientY);
+ }, [applyJoystick]);
+
+ const handleMouseUp = useCallback(() => {
+ mouseDraggingRef.current = false;
+ resetJoystick();
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ }, [resetJoystick, handleMouseMove]);
+
+ const handleMouseDown = useCallback((e) => {
+ e.preventDefault();
+ mouseDraggingRef.current = true;
+ applyJoystick(e.clientX, e.clientY);
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }, [applyJoystick, handleMouseMove, handleMouseUp]);
+
+ // Keyboard input
+ useEffect(() => {
+ const arrowMap = { ArrowUp: 'w', ArrowDown: 's', ArrowLeft: 'a', ArrowRight: 'd' };
+
+ const handleKey = (e, pressed) => {
+ const k = arrowMap[e.key] || e.key.toLowerCase();
+ if ('wasd'.includes(k) && k.length === 1) {
+ e.preventDefault();
+ setKeys((prev) => {
+ const next = { ...prev, [k]: pressed };
+ const x = -(next.d ? 1 : 0) + (next.a ? 1 : 0);
+ const y = -(next.w ? 1 : 0) + (next.s ? 1 : 0);
+ setFlippedJoystick(y, x);
+ const anyKey = next.w || next.a || next.s || next.d;
+ setThumbPos(anyKey ? { x: -x, y } : null);
+ return next;
+ });
+ }
+ if (pressed) {
+ const cameraKeys = { 1: 'wideRoad', 2: 'driver' };
+ if (cameraKeys[e.key]) {
+ e.preventDefault();
+ onSwitchCamera(cameraKeys[e.key]);
+ }
+ }
+ };
+
+ const onKeyDown = (e) => handleKey(e, true);
+ const onKeyUp = (e) => handleKey(e, false);
+
+ document.addEventListener('keydown', onKeyDown);
+ document.addEventListener('keyup', onKeyUp);
+ return () => {
+ document.removeEventListener('keydown', onKeyDown);
+ document.removeEventListener('keyup', onKeyUp);
+ };
+ }, [setFlippedJoystick, onSwitchCamera]);
+
+ // Gamepad polling
+ const gamepadConnectedRef = useRef(gamepadConnected);
+ useEffect(() => { gamepadConnectedRef.current = gamepadConnected; }, [gamepadConnected]);
+
+ useEffect(() => {
+ const activated = triggerActivatedRef.current;
+
+ const pollGamepad = () => {
+ gamepadFrameRef.current = requestAnimationFrame(pollGamepad);
+ const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
+ const gp = gamepads[0] || gamepads[1] || gamepads[2] || gamepads[3];
+
+ if (!gp) {
+ if (gamepadConnectedRef.current) onGamepadChange(false);
+ return;
+ }
+
+ if (!gamepadConnectedRef.current) onGamepadChange(true);
+
+ const DEADZONE = 0.15;
+ let lx = gp.axes[0] || 0;
+ if (Math.abs(lx) < DEADZONE) lx = 0;
+
+ const rawRt = gp.axes[5] !== undefined ? gp.axes[5] : undefined;
+ const rawLt = gp.axes[4] !== undefined ? gp.axes[4] : undefined;
+ if (rawRt !== undefined && rawRt !== 0) activated.rt = true;
+ if (rawLt !== undefined && rawLt !== 0) activated.lt = true;
+ const rt = rawRt !== undefined ? ((activated.rt ? rawRt : -1) + 1) / 2
+ : gp.buttons[7] ? gp.buttons[7].value : 0;
+ const lt = rawLt !== undefined ? ((activated.lt ? rawLt : -1) + 1) / 2
+ : gp.buttons[6] ? gp.buttons[6].value : 0;
+
+ const throttle = lt - rt;
+ const lb = gp.buttons[4] && gp.buttons[4].pressed;
+ const rb = gp.buttons[5] && gp.buttons[5].pressed;
+
+ const pg = prevGamepadRef.current;
+ if (lx !== pg.steering || rt !== pg.gas || lt !== pg.brake || !!lb !== pg.lb || !!rb !== pg.rb) {
+ const next = { steering: lx, gas: rt, brake: lt, lb: !!lb, rb: !!rb };
+ prevGamepadRef.current = next;
+ setGamepadState(next);
+ }
+
+ setFlippedJoystick(throttle, -lx);
+
+ if (lx !== 0 || rt > 0 || lt > 0) {
+ const pt = prevThumbRef.current;
+ if (!pt || pt.x !== lx || pt.y !== throttle) {
+ const next = { x: lx, y: throttle };
+ prevThumbRef.current = next;
+ setThumbPos(next);
+ }
+ } else if (touchIdRef.current === null && !mouseDraggingRef.current) {
+ if (prevThumbRef.current !== null) {
+ prevThumbRef.current = null;
+ setThumbPos(null);
+ }
+ }
+
+ const prev = prevBumpersRef.current;
+ if (lb && !prev.lb) onSwitchCamera('driver');
+ if (rb && !prev.rb) onSwitchCamera('wideRoad');
+ prevBumpersRef.current = { lb, rb };
+ };
+
+ gamepadFrameRef.current = requestAnimationFrame(pollGamepad);
+ return () => {
+ if (gamepadFrameRef.current) cancelAnimationFrame(gamepadFrameRef.current);
+ };
+ }, [setFlippedJoystick, onGamepadChange, onSwitchCamera]);
+
+ // Cleanup mouse listeners on unmount
+ useEffect(() => {
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [handleMouseMove, handleMouseUp]);
+
+ if (gamepadConnected) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+export default Joystick;
diff --git a/src/components/BodyTeleop/StatusBar.jsx b/src/components/BodyTeleop/StatusBar.jsx
new file mode 100644
index 00000000..8680f512
--- /dev/null
+++ b/src/components/BodyTeleop/StatusBar.jsx
@@ -0,0 +1,252 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import BatteryFull from '@material-ui/icons/BatteryFull';
+import BatteryChargingFull from '@material-ui/icons/BatteryChargingFull';
+
+const LATENCY_BUFFER_SIZE = 10;
+const LATENCY_HISTORY_MAX = 60;
+
+const STATS_ROWS = [
+ { label: 'Resolution', key: 'resolution' },
+ { label: 'FPS', key: 'fps' },
+ { label: 'Bitrate', key: 'bitrate' },
+ { label: 'Jitter', key: 'jitter' },
+];
+
+const LATENCY_LAYERS = [
+ { label: 'Capture', key: 'captureMs', color: 'rgba(76,175,80,0.55)', labelColor: 'rgba(76,175,80,0.7)' },
+ { label: 'Encode', key: 'encodeMs', color: 'rgba(255,183,77,0.55)', labelColor: 'rgba(255,183,77,0.7)' },
+ { label: 'Send delay', key: 'sendDelayMs', color: 'rgba(171,71,188,0.45)', labelColor: 'rgba(171,71,188,0.65)' },
+ { label: 'Network', key: 'networkMs', color: 'rgba(66,165,245,0.55)', labelColor: 'rgba(66,165,245,0.7)' },
+];
+
+
+export const useStats = (connection, connectionState, latencyCallbackRef) => {
+ const [showStats, setShowStats] = useState(false);
+ const [stats, setStats] = useState(null);
+ const [latency, setLatency] = useState(null);
+ const [latencyHistory, setLatencyHistory] = useState([]);
+ const latencyBufferRef = useRef([]);
+ const statsPollingRef = useRef({ interval: null, prevTimestamp: null, prevBytes: null, prevFrames: null });
+
+ useEffect(() => {
+ latencyCallbackRef.current = (raw) => {
+ latencyBufferRef.current.push(raw);
+ if (latencyBufferRef.current.length >= LATENCY_BUFFER_SIZE) {
+ const buf = latencyBufferRef.current;
+ latencyBufferRef.current = [];
+ const avg = {};
+ for (const key of ['captureMs', 'encodeMs', 'sendDelayMs', 'devicePipelineMs', 'networkMs', 'totalMs']) {
+ const vals = buf.map((l) => l[key]).filter((v) => v != null);
+ avg[key] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
+ }
+ setLatency(avg);
+ setLatencyHistory((prev) => [...prev, avg].slice(-LATENCY_HISTORY_MAX));
+ }
+ };
+ return () => { latencyCallbackRef.current = null; };
+ }, [latencyCallbackRef]);
+
+ const pollStats = useCallback(async () => {
+ const pc = connection?.pc;
+ if (!pc) return;
+ try {
+ const report = await pc.getStats();
+ let videoStats = null;
+ report.forEach((stat) => {
+ if (stat.type === 'inbound-rtp' && stat.kind === 'video') videoStats = stat;
+ });
+ if (!videoStats) return;
+
+ const ref = statsPollingRef.current;
+ const now = videoStats.timestamp;
+ let bitrate = 0;
+ let fps = 0;
+ if (ref.prevTimestamp !== null) {
+ const elapsed = (now - ref.prevTimestamp) / 1000;
+ if (elapsed > 0) {
+ bitrate = ((videoStats.bytesReceived - ref.prevBytes) * 8) / elapsed;
+ fps = (videoStats.framesDecoded - ref.prevFrames) / elapsed;
+ }
+ }
+ ref.prevTimestamp = now;
+ ref.prevBytes = videoStats.bytesReceived;
+ ref.prevFrames = videoStats.framesDecoded;
+
+ setStats({
+ resolution: `${videoStats.frameWidth || '?'}x${videoStats.frameHeight || '?'}`,
+ fps: fps.toFixed(1),
+ bitrate: bitrate > 1000000
+ ? `${(bitrate / 1000000).toFixed(2)} Mbps`
+ : `${(bitrate / 1000).toFixed(0)} kbps`,
+ jitter: videoStats.jitter !== undefined ? `${(videoStats.jitter * 1000).toFixed(1)} ms` : '?',
+ });
+ } catch (err) {
+ console.warn('pollStats failed:', err);
+ }
+ }, [connection]);
+
+ useEffect(() => {
+ const ref = statsPollingRef.current;
+ if (connectionState === 'connected') {
+ ref.prevTimestamp = null;
+ ref.prevBytes = null;
+ ref.prevFrames = null;
+ pollStats();
+ ref.interval = setInterval(pollStats, 1000);
+ } else {
+ if (ref.interval) clearInterval(ref.interval);
+ ref.interval = null;
+ setStats(null);
+ setLatency(null);
+ setLatencyHistory([]);
+ }
+ return () => {
+ if (ref.interval) clearInterval(ref.interval);
+ ref.interval = null;
+ };
+ }, [connectionState, pollStats]);
+
+ const toggleStats = useCallback(() => {
+ setShowStats((prev) => {
+ const next = !prev;
+ connection?.setTimingSei(next);
+ return next;
+ });
+ }, [connection]);
+
+ return { showStats, toggleStats, stats, latency, latencyHistory };
+}
+
+function drawLatencyGraph(canvas, latencyHistory) {
+ const ctx = canvas.getContext('2d');
+ const dpr = window.devicePixelRatio || 1;
+ const w = canvas.clientWidth;
+ const h = canvas.clientHeight;
+ const newW = w * dpr;
+ const newH = h * dpr;
+ if (canvas.width !== newW || canvas.height !== newH) {
+ canvas.width = newW;
+ canvas.height = newH;
+ }
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, w, h);
+
+ const maxVal = Math.max(10, ...latencyHistory.map((l) => (l.totalMs != null ? l.totalMs : l.devicePipelineMs) || 0));
+ const yScale = (h - 2) / (maxVal * 1.15);
+ const xStep = w / Math.max(latencyHistory.length - 1, 1);
+
+ const cums = latencyHistory.map((l) => {
+ let sum = 0;
+ return LATENCY_LAYERS.map(({ key }) => {
+ const v = l[key];
+ sum += (v != null && v > 0) ? v : 0;
+ return sum;
+ });
+ });
+
+ for (let li = LATENCY_LAYERS.length - 1; li >= 0; li--) {
+ ctx.beginPath();
+ for (let i = 0; i < cums.length; i++) {
+ const x = i * xStep;
+ const y = h - cums[i][li] * yScale;
+ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
+ }
+ ctx.lineTo((cums.length - 1) * xStep, h);
+ ctx.lineTo(0, h);
+ ctx.closePath();
+ ctx.fillStyle = LATENCY_LAYERS[li].color;
+ ctx.fill();
+ }
+
+ ctx.fillStyle = 'rgba(255,255,255,0.3)';
+ ctx.font = '8px monospace';
+ ctx.fillText(`${Math.round(maxVal)}ms`, 2, 9);
+}
+
+export const StatsPanel = ({isLandscape, stats, latency, latencyHistory }) => {
+ const latencyCanvasRef = useRef(null);
+
+ useEffect(() => {
+ if (!latencyHistory.length) return;
+ const canvas = latencyCanvasRef.current;
+ if (!canvas) return;
+ drawLatencyGraph(canvas, latencyHistory);
+ }, [latencyHistory]);
+
+ if (!stats) return null;
+
+ const fmtMs = (v) => (v != null ? `${v.toFixed(1)} ms` : '--');
+
+ return (
+
+
+ {STATS_ROWS.map(({ label, key }) => (
+
+ {label}
+ {stats[key]}
+
+ ))}
+
+ {latency && (
+ <>
+
FRAME LATENCY
+ {LATENCY_LAYERS.map(({ label, key, labelColor }) => (
+
+ {label}
+ {fmtMs(latency[key])}
+
+ ))}
+
+ Total
+ {fmtMs(latency.totalMs)}
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+const StatusBar = ({
+ connectionState, battery, className, toggleStats,
+}) => {
+ const dotColor = connectionState === 'connecting' ? '#facc15'
+ : connectionState === 'connected' ? '#22c967'
+ : connectionState === 'failed' ? '#da2535'
+ : '#4b5559';
+
+ const BatteryIcon = battery?.charging ? BatteryChargingFull : BatteryFull;
+
+ return (
+
+
+ {battery && (
+
+
+ {battery.level}%
+
+ )}
+
+ stats
+
+
+ );
+};
+
+export default StatusBar;
diff --git a/src/components/BodyTeleop/Video.jsx b/src/components/BodyTeleop/Video.jsx
new file mode 100644
index 00000000..457ef446
--- /dev/null
+++ b/src/components/BodyTeleop/Video.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { Button } from '@material-ui/core';
+import Refresh from '@material-ui/icons/Refresh';
+import InfoOutline from '@material-ui/icons/InfoOutline';
+
+
+const ConnectOverlay = ({ connectionState, error, statusMessage, connectProgress, onConnect }) => {
+ const connecting = connectionState === 'connecting';
+ const failed = connectionState === 'failed';
+
+ return (
+
+
+ {connecting ? (
+ <>
+
+
{statusMessage || 'Connecting...'}
+ >
+ ) : failed ? (
+
+ ) : null}
+ {error && (
+
+ {error}
+
+ )}
+ {!connecting && (
+
+
+ Body must be powered on and started.
+
+ )}
+
+
+ );
+};
+
+const Video = ({
+ videoRef, connectionState, error, statusMessage,
+ connectProgress, onConnect, fit = 'contain',
+}) => {
+ const connected = connectionState === 'connected';
+
+ return (
+ <>
+
+ {!connected && (
+
+ )}
+ >
+ );
+};
+
+export default Video;
diff --git a/src/components/BodyTeleop/index.jsx b/src/components/BodyTeleop/index.jsx
new file mode 100644
index 00000000..0d29d628
--- /dev/null
+++ b/src/components/BodyTeleop/index.jsx
@@ -0,0 +1,250 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import { connect } from 'react-redux';
+import Obstruction from 'obstruction';
+import { IconButton, Typography } from '@material-ui/core';
+
+import { ArrowBackBold } from '../../icons';
+import { deviceNamePretty } from '../../utils';
+import { BodyTeleopConnection } from '../../utils/bodyteleop';
+import StatusBar, { useStats, StatsPanel } from './StatusBar';
+import ControlsBar from './ControlsBar';
+import Video from './Video';
+import Joystick from './Joystick';
+
+const progressMap = {
+ 'Preparing connection...': 10,
+ 'Finding network path...': 20,
+ 'Reaching device...': 30,
+ 'Device responded': 85,
+ 'Establishing connection...': 92,
+ 'Receiving video...': 97,
+};
+
+const BodyTeleop = ({ dongleId, device, directAddress, onClose }) => {
+ const [connectionState, setConnectionState] = useState('disconnected');
+ const [statusMessage, setStatusMessage] = useState(null);
+ const [connectProgress, setConnectProgress] = useState(0);
+ const [battery, setBattery] = useState(null);
+ const [error, setError] = useState(null);
+ const [isLandscape, setIsLandscape] = useState(false);
+ const [activeCamera, setActiveCamera] = useState('wideRoad');
+
+ const [gamepadConnected, setGamepadConnected] = useState(false);
+
+ const videoRef = useRef(null);
+ const streamsRef = useRef({});
+ const connectionRef = useRef(null);
+ const latencyCallbackRef = useRef(null);
+ const switchTimerRef = useRef(null);
+
+ useEffect(() => {
+ const conn = new BodyTeleopConnection({
+ onConnectionState: (state) => {
+ setConnectionState(state);
+ if (state !== 'connecting') {
+ setStatusMessage(null);
+ setConnectProgress(0);
+ }
+ },
+ onStatusMessage: (msg) => {
+ setStatusMessage(msg);
+ setConnectProgress(progressMap[msg] || 0);
+ },
+ onBatteryLevel: setBattery,
+ onConnectionReplaced: (data) => {
+ setError(data || 'Connection replaced');
+ setConnectionState('failed');
+ conn.cleanup();
+ },
+ onVideoTrack: (_cameraName, stream) => {
+ streamsRef.current.camera = stream;
+ if (videoRef.current) {
+ videoRef.current.srcObject = stream;
+ }
+ },
+ onLatencyUpdate: (latency) => {
+ if (latencyCallbackRef.current) latencyCallbackRef.current(latency);
+ },
+ });
+
+ connectionRef.current = conn;
+ const onBeforeUnload = () => conn.disconnect();
+ window.addEventListener('beforeunload', onBeforeUnload);
+
+ return () => {
+ window.removeEventListener('beforeunload', onBeforeUnload);
+ conn.disconnect();
+ };
+ }, []);
+
+ useEffect(() => {
+ const query = window.matchMedia('(orientation: landscape)');
+ setIsLandscape(query.matches);
+ const handler = (e) => setIsLandscape(e.matches);
+ query.addEventListener('change', handler);
+ return () => query.removeEventListener('change', handler);
+ }, []);
+
+ useEffect(() => {
+ // Re-attach video stream on orientation change
+ const stream = streamsRef.current.camera || null;
+ if (videoRef.current && videoRef.current.srcObject !== stream) {
+ videoRef.current.srcObject = stream;
+ }
+ }, [isLandscape]);
+
+ const handleConnect = useCallback(async () => {
+ const conn = connectionRef.current;
+ if (!conn) return;
+ setError(null);
+ setActiveCamera('wideRoad');
+ try {
+ if (directAddress) {
+ await conn.connectDirect(directAddress);
+ } else {
+ await conn.connect(dongleId);
+ }
+ } catch (err) {
+ setError(err.message);
+ }
+ }, [dongleId, directAddress]);
+
+ useEffect(() => {
+ handleConnect();
+ }, []);
+
+ const handleDisconnect = useCallback(() => {
+ setError(null);
+ connectionRef.current?.disconnect();
+ }, []);
+
+ const handleClose = useCallback(() => {
+ handleDisconnect();
+ if (onClose) onClose();
+ }, [handleDisconnect, onClose]);
+
+ const switchCamera = useCallback((cameraName) => {
+ setActiveCamera((prev) => {
+ if (cameraName === prev) return prev;
+ if (switchTimerRef.current) clearTimeout(switchTimerRef.current);
+ switchTimerRef.current = setTimeout(() => {
+ switchTimerRef.current = null;
+ connectionRef.current?.switchCamera(cameraName);
+ }, 200);
+ return cameraName;
+ });
+ }, []);
+
+ const connection = connectionRef.current;
+ const connected = connectionState === 'connected';
+ const deviceName = directAddress || (device ? deviceNamePretty(device) : (isLandscape ? 'Body' : 'Body Teleop'));
+
+ const statsState = useStats(connection, connectionState, latencyCallbackRef);
+ const videoProps = {
+ videoRef, connectionState, error,
+ statusMessage, connectProgress,
+ onConnect: handleConnect,
+ };
+
+ if (isLandscape) {
+ return (
+
+
+
+
+
+
+
+
+ {deviceName}
+
+
+ {connected && (
+ <>
+
+ {statsState.showStats && (
+
+ )}
+
+
+ >
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+ {connected && (
+
+ )}
+
+
+ {connected && statsState.showStats && (
+
+ )}
+
+ {connected ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+
+ );
+};
+
+const stateToProps = Obstruction({
+ dongleId: 'dongleId',
+ device: 'device',
+});
+
+export default connect(stateToProps)(BodyTeleop);
diff --git a/src/components/DeviceInfo/index.jsx b/src/components/DeviceInfo/index.jsx
index e0fb0383..8c67f23f 100644
--- a/src/components/DeviceInfo/index.jsx
+++ b/src/components/DeviceInfo/index.jsx
@@ -7,10 +7,12 @@ import dayjs from 'dayjs';
import { withStyles, Typography, Button, CircularProgress, Popper, Tooltip } from '@material-ui/core';
import AccessTime from '@material-ui/icons/AccessTime';
+import { push } from 'connected-react-router';
import { athena as Athena, devices as Devices } from '@commaai/api';
import { analyticsEvent, primeNav } from '../../actions';
import Colors from '../../colors';
-import { deviceNamePretty, deviceIsOnline } from '../../utils';
+import { GamepadIcon } from '../../icons';
+import { deviceNamePretty, deviceIsOnline, deviceVersionAtLeast } from '../../utils';
import { isMetric, KM_PER_MI } from '../../utils/conversions';
import ResizeHandler from '../ResizeHandler';
import VisibilityHandler from '../VisibilityHandler';
@@ -206,6 +208,7 @@ class DeviceInfo extends Component {
snapshot: {},
windowWidth: window.innerWidth,
isTimeSelectOpen: false,
+ isCommaBody: false,
};
this.snapshotButtonRef = React.createRef();
@@ -214,6 +217,7 @@ class DeviceInfo extends Component {
this.onVisible = this.onVisible.bind(this);
this.fetchDeviceInfo = this.fetchDeviceInfo.bind(this);
this.fetchDeviceCarHealth = this.fetchDeviceCarHealth.bind(this);
+ this.fetchIsNotCar = this.fetchIsNotCar.bind(this);
this.takeSnapshot = this.takeSnapshot.bind(this);
this.snapshotType = this.snapshotType.bind(this);
this.renderButtons = this.renderButtons.bind(this);
@@ -236,6 +240,7 @@ class DeviceInfo extends Component {
carHealth: {},
snapshot: {},
windowWidth: window.innerWidth,
+ isCommaBody: false,
});
}
}
@@ -253,6 +258,7 @@ class DeviceInfo extends Component {
if (!device.shared) {
this.fetchDeviceInfo();
this.fetchDeviceCarHealth();
+ this.fetchIsNotCar();
}
}
@@ -304,6 +310,29 @@ class DeviceInfo extends Component {
}
}
+ async fetchIsNotCar() {
+ const { dongleId, device } = this.props;
+ if (!deviceIsOnline(device)) {
+ return;
+ }
+
+ try {
+ const payload = {
+ method: 'getNotCar',
+ jsonrpc: '2.0',
+ id: 0,
+ };
+ const resp = await Athena.postJsonRpcPayload(dongleId, payload);
+ if (this.mounted && dongleId === this.props.dongleId) {
+ this.setState({ isCommaBody: resp.result === true });
+ }
+ } catch (err) {
+ if (!err.message || err.message.indexOf('Device not registered') === -1) {
+ console.error(err);
+ }
+ }
+ }
+
async takeSnapshot() {
const { dongleId } = this.props;
const { snapshot } = this.state;
@@ -478,7 +507,7 @@ class DeviceInfo extends Component {
renderButtons() {
const { classes, device } = this.props;
- const { snapshot, carHealth, windowWidth, isTimeSelectOpen } = this.state;
+ const { snapshot, carHealth, windowWidth, isTimeSelectOpen, isCommaBody } = this.state;
let batteryVoltage;
let batteryBackground = Colors.grey400;
@@ -508,8 +537,28 @@ class DeviceInfo extends Component {
pingTooltip = `Last ping on ${lastAthenaPing.format('MMM D, YYYY')} at ${lastAthenaPing.format('h:mm A')}`;
}
+ const bodyTeleopEnabled = isCommaBody && deviceVersionAtLeast(device, '0.11.2');
+
return (
<>
+ {bodyTeleopEnabled && (
+
+
+
+
+
+ )}
)}
-
+ {!bodyTeleopEnabled && (
+
+ )}