Initial web ui and control for robot

This commit is contained in:
2026-04-30 21:35:18 +00:00
parent d64e1c24c8
commit 91f5f4d3ab
23 changed files with 2688 additions and 4 deletions
@@ -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' }}
/>
)
}