Initial web ui and control for robot
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Copy this file to .env.local and edit for your setup.
|
||||
# .env.local is gitignored — safe for local overrides.
|
||||
|
||||
# Hostname of the WebRTC signaling server (GStreamer webrtcsink) for the video stream.
|
||||
# Defaults to the same host as the page when unset (fine for production).
|
||||
# Override to point at a remote robot during development:
|
||||
# VITE_WEBRTC_HOST=rapsbot-v2.local
|
||||
VITE_WEBRTC_HOST=
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env.local
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Raspbot Control</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #111; color: #eee; font-family: monospace; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1794
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "raspbot-webui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState } from 'react'
|
||||
import { CameraControls } from './components/CameraControls.jsx'
|
||||
import { RobotControls } from './components/RobotControls.jsx'
|
||||
import { VideoStream } from './components/VideoStream.jsx'
|
||||
import { useWebSocket } from './hooks/useWebSocket.js'
|
||||
|
||||
export default function App() {
|
||||
const [jointStates, setJointStates] = useState({})
|
||||
const [range, setRange] = useState(null)
|
||||
|
||||
const send = useWebSocket((msg) => {
|
||||
if (msg.type === 'joint_states') setJointStates(msg.positions)
|
||||
if (msg.type === 'ultrasonic') setRange(msg.range)
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', padding: 8, gap: 8 }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, color: '#4ade80', letterSpacing: 2 }}>RASPBOT</span>
|
||||
{range !== null && (
|
||||
<span style={{ fontSize: 12, color: range < 0.3 ? '#f87171' : '#facc15' }}>
|
||||
▶ {range.toFixed(2)} m
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Video */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<VideoStream />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flex: 1 }}>
|
||||
<RobotControls send={send} />
|
||||
<CameraControls send={send} jointStates={jointStates} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const PAN_MIN = -Math.PI / 2 // -90°
|
||||
const PAN_MAX = Math.PI / 2 // +90°
|
||||
const TILT_MIN = -0.524 // -30°
|
||||
const TILT_MAX = 0.960 // +55°
|
||||
|
||||
const toDeg = (r) => Math.round(r * 180 / Math.PI)
|
||||
|
||||
export function CameraControls({ send, jointStates }) {
|
||||
const [pan, setPan] = useState(0)
|
||||
const [tilt, setTilt] = useState(0)
|
||||
|
||||
const sendCmd = (p, t) => send({ type: 'joint_command', pan: p, tilt: t })
|
||||
|
||||
const row = { display: 'flex', flexDirection: 'column', gap: 4 }
|
||||
const label = { fontSize: 12, color: '#888', display: 'flex', justifyContent: 'space-between' }
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 180 }}>
|
||||
<div style={{ fontSize: 12, color: '#555', borderBottom: '1px solid #2a2a2a', paddingBottom: 4 }}>
|
||||
Camera
|
||||
</div>
|
||||
|
||||
<div style={row}>
|
||||
<div style={label}>
|
||||
<span>Pan</span>
|
||||
<span style={{ color: '#4ade80' }}>{toDeg(jointStates?.pan ?? pan)}°</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={PAN_MIN} max={PAN_MAX} step={0.01}
|
||||
value={pan}
|
||||
style={{ accentColor: '#4ade80', width: '100%' }}
|
||||
onChange={(e) => { const v = +e.target.value; setPan(v); sendCmd(v, tilt) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={row}>
|
||||
<div style={label}>
|
||||
<span>Tilt</span>
|
||||
<span style={{ color: '#4ade80' }}>{toDeg(jointStates?.tilt ?? tilt)}°</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={TILT_MIN} max={TILT_MAX} step={0.01}
|
||||
value={tilt}
|
||||
style={{ accentColor: '#4ade80', width: '100%' }}
|
||||
onChange={(e) => { const v = +e.target.value; setTilt(v); sendCmd(pan, v) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setPan(0); setTilt(0); sendCmd(0, 0) }}
|
||||
style={{
|
||||
background: '#2a2a2a', color: '#aaa', border: '1px solid #444',
|
||||
padding: '4px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Centre
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const LINEAR_SPEED = 0.3 // m/s
|
||||
const ANGULAR_SPEED = 1.0 // rad/s
|
||||
const SEND_HZ = 100 // ms between cmd_vel publishes
|
||||
|
||||
// Keys that map to velocity deltas
|
||||
const KEY_VELS = {
|
||||
w: { linear_x: LINEAR_SPEED, angular_z: 0 },
|
||||
s: { linear_x: -LINEAR_SPEED, angular_z: 0 },
|
||||
a: { linear_x: 0, angular_z: ANGULAR_SPEED },
|
||||
d: { linear_x: 0, angular_z: -ANGULAR_SPEED },
|
||||
ArrowUp: { linear_x: LINEAR_SPEED, angular_z: 0 },
|
||||
ArrowDown: { linear_x: -LINEAR_SPEED, angular_z: 0 },
|
||||
ArrowLeft: { linear_x: 0, angular_z: ANGULAR_SPEED },
|
||||
ArrowRight: { linear_x: 0, angular_z: -ANGULAR_SPEED },
|
||||
}
|
||||
|
||||
// Map D-pad button ids to their keyboard equivalents
|
||||
const DPAD = [
|
||||
{ id: 'w', label: '▲', row: 0, col: 1 },
|
||||
{ id: 'a', label: '◄', row: 1, col: 0 },
|
||||
{ id: 's', label: '▼', row: 1, col: 1 },
|
||||
{ id: 'd', label: '►', row: 1, col: 2 },
|
||||
]
|
||||
|
||||
export function RobotControls({ send }) {
|
||||
const keysRef = useRef(new Set())
|
||||
const [activeKeys, setActiveKeys] = useState(new Set())
|
||||
|
||||
const computeVelocity = () => {
|
||||
let linear_x = 0, angular_z = 0
|
||||
for (const k of keysRef.current) {
|
||||
const v = KEY_VELS[k]
|
||||
if (v) { linear_x += v.linear_x; angular_z += v.angular_z }
|
||||
}
|
||||
return {
|
||||
linear_x: Math.max(-LINEAR_SPEED, Math.min(LINEAR_SPEED, linear_x)),
|
||||
angular_z: Math.max(-ANGULAR_SPEED, Math.min(ANGULAR_SPEED, angular_z)),
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const press = (key) => {
|
||||
if (!KEY_VELS[key] || keysRef.current.has(key)) return
|
||||
keysRef.current.add(key)
|
||||
setActiveKeys(new Set(keysRef.current))
|
||||
}
|
||||
const release = (key) => {
|
||||
if (!KEY_VELS[key]) return
|
||||
keysRef.current.delete(key)
|
||||
setActiveKeys(new Set(keysRef.current))
|
||||
}
|
||||
|
||||
const onKeyDown = (e) => { press(e.key); if (KEY_VELS[e.key]) e.preventDefault() }
|
||||
const onKeyUp = (e) => { release(e.key); if (KEY_VELS[e.key]) e.preventDefault() }
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
const timer = setInterval(() => send({ type: 'cmd_vel', ...computeVelocity() }), SEND_HZ)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
clearInterval(timer)
|
||||
send({ type: 'cmd_vel', linear_x: 0, angular_z: 0 })
|
||||
}
|
||||
}, [send])
|
||||
|
||||
// Aliases: treat w/ArrowUp etc. as the same logical key for D-pad highlighting
|
||||
const isActive = (id) => activeKeys.has(id) || activeKeys.has(
|
||||
{ w: 'ArrowUp', s: 'ArrowDown', a: 'ArrowLeft', d: 'ArrowRight' }[id]
|
||||
)
|
||||
|
||||
const dpadPress = (id) => () => {
|
||||
keysRef.current.add(id)
|
||||
setActiveKeys(new Set(keysRef.current))
|
||||
}
|
||||
const dpadRelease = (id) => () => {
|
||||
keysRef.current.delete(id)
|
||||
setActiveKeys(new Set(keysRef.current))
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 48px)', gridTemplateRows: 'repeat(2, 48px)', gap: 4 }}>
|
||||
{DPAD.map(({ id, label, row, col }) => (
|
||||
<button
|
||||
key={id}
|
||||
onMouseDown={dpadPress(id)}
|
||||
onMouseUp={dpadRelease(id)}
|
||||
onMouseLeave={dpadRelease(id)}
|
||||
onTouchStart={(e) => { e.preventDefault(); dpadPress(id)() }}
|
||||
onTouchEnd={(e) => { e.preventDefault(); dpadRelease(id)() }}
|
||||
style={{
|
||||
gridRow: row + 1, gridColumn: col + 1,
|
||||
background: isActive(id) ? '#4ade80' : '#2a2a2a',
|
||||
color: isActive(id) ? '#000' : '#aaa',
|
||||
border: '1px solid #444', borderRadius: 6,
|
||||
cursor: 'pointer', fontSize: 18, userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#555' }}>WASD / arrow keys</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
// Signaling host for the GStreamer WebRTC server (hostname only, e.g. rapsbot-v2.local).
|
||||
// Set VITE_WEBRTC_HOST in .env.local or as an env var; defaults to the current page host.
|
||||
const signalingHost = import.meta.env.VITE_WEBRTC_HOST || window.location.hostname
|
||||
const SIGNALING_WS_URL = `ws://${signalingHost}:8443`
|
||||
|
||||
export function VideoStream() {
|
||||
const videoRef = useRef(null)
|
||||
const pcRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[WebRTC] Connecting to', SIGNALING_WS_URL)
|
||||
const pc = new RTCPeerConnection({})
|
||||
const ws = new WebSocket(SIGNALING_WS_URL)
|
||||
|
||||
pcRef.current = pc
|
||||
wsRef.current = ws
|
||||
|
||||
pc.onconnectionstatechange = () => console.log('[WebRTC] PC state:', pc.connectionState)
|
||||
pc.oniceconnectionstatechange = () => console.log('[WebRTC] ICE state:', pc.iceConnectionState)
|
||||
|
||||
pc.ontrack = ({ streams }) => {
|
||||
console.log('[WebRTC] Track received, streams:', streams.length)
|
||||
if (videoRef.current) videoRef.current.srcObject = streams[0]
|
||||
}
|
||||
|
||||
pc.onicecandidate = ({ candidate }) => {
|
||||
if (candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ice',
|
||||
mlineindex: candidate.sdpMLineIndex,
|
||||
candidate: candidate.candidate,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => console.log('[WebRTC] Signaling connected')
|
||||
ws.onclose = () => console.log('[WebRTC] Signaling closed')
|
||||
|
||||
ws.onmessage = async (event) => {
|
||||
const msg = JSON.parse(event.data)
|
||||
console.log('[WebRTC] Signal:', msg.type, msg)
|
||||
|
||||
if (msg.type === 'offer') {
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp })
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
ws.send(JSON.stringify({ type: 'answer', sdp: answer.sdp }))
|
||||
console.log('[WebRTC] Answer sent')
|
||||
} else if (msg.type === 'ice') {
|
||||
await pc.addIceCandidate({ sdpMLineIndex: msg.mlineindex, candidate: msg.candidate })
|
||||
}
|
||||
}
|
||||
|
||||
let cleanedUp = false
|
||||
ws.onerror = (e) => {
|
||||
if (!cleanedUp) console.error('[WebRTC] Signaling error', e)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanedUp = true
|
||||
pc.close()
|
||||
ws.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{ width: '100%', maxHeight: '60vh', background: '#000', display: 'block' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
export function useWebSocket(onMessage) {
|
||||
const wsRef = useRef(null)
|
||||
const onMessageRef = useRef(onMessage)
|
||||
onMessageRef.current = onMessage
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(`ws://${window.location.host}/ws`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try { onMessageRef.current(JSON.parse(e.data)) } catch {}
|
||||
}
|
||||
let cleanedUp = false
|
||||
ws.onerror = (e) => {
|
||||
if (!cleanedUp) console.error('WebSocket error', e)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cleanedUp = true
|
||||
ws.close()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useCallback((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data))
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/offer': 'http://localhost:8080',
|
||||
'/ws': { target: 'ws://localhost:8080', ws: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user