Read constraints from the published URDF rather than hard code them.
Also make the webui more responsive for small screens.
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #111; color: #eee; font-family: monospace; overflow: hidden; }
|
||||
@media (max-width: 640px) { body { overflow-y: auto; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+32
-14
@@ -6,37 +6,55 @@ 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 [jointStates, setJointStates] = useState({})
|
||||
const [range, setRange] = useState(null)
|
||||
const [jointLimits, setJointLimits] = useState(null)
|
||||
|
||||
const send = useWebSocket((msg) => {
|
||||
if (msg.type === 'joint_states') setJointStates(msg.positions)
|
||||
if (msg.type === 'ultrasonic') setRange(msg.range)
|
||||
const { send, connected } = useWebSocket((msg) => {
|
||||
if (msg.type === 'joint_states') { console.log('[joint_states]', msg.positions); setJointStates(msg.positions) }
|
||||
if (msg.type === 'ultrasonic') setRange(msg.range)
|
||||
if (msg.type === 'joint_limits') setJointLimits(msg.limits)
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', padding: 8, gap: 8 }}>
|
||||
<div className="app-layout">
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
<div className="app-header">
|
||||
<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
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{range !== null
|
||||
? <span style={{ fontSize: 12, color: range < 0.3 ? '#f87171' : '#facc15' }}>
|
||||
▶ {range.toFixed(2)} m
|
||||
</span>
|
||||
: <Waiting topic="/ultrasonic/range" />
|
||||
}
|
||||
<span style={{ fontSize: 11, color: connected ? '#4ade80' : '#f87171' }}>
|
||||
{connected ? '● ROS' : '○ connecting…'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<div className="app-video">
|
||||
<VideoStream />
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 16, flex: 1 }}>
|
||||
<div className="controls-row">
|
||||
<RobotControls send={send} />
|
||||
<CameraControls send={send} jointStates={jointStates} />
|
||||
<CameraControls send={send} jointStates={jointStates} limits={jointLimits} />
|
||||
<LedControls send={send} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Waiting({ topic }) {
|
||||
return (
|
||||
<span style={{ fontSize: 11, color: '#555', fontStyle: 'italic' }}>
|
||||
waiting for <span style={{ fontFamily: 'monospace', color: '#666' }}>{topic}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,60 +1,76 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const PAN_MIN = -Math.PI / 3 // -60°
|
||||
const PAN_MAX = Math.PI / 2 // +90°
|
||||
const TILT_MIN = -Math.PI / 2 // -90°
|
||||
const TILT_MAX = Math.PI / 4 // +45°
|
||||
|
||||
const toDeg = (r) => Math.round(r * 180 / Math.PI)
|
||||
|
||||
export function CameraControls({ send, jointStates }) {
|
||||
const [pan, setPan] = useState(0)
|
||||
function Waiting({ topic }) {
|
||||
return (
|
||||
<div style={{ fontSize: 11, color: '#555', fontStyle: 'italic', padding: '4px 0' }}>
|
||||
waiting for <span style={{ fontFamily: 'monospace', color: '#666' }}>{topic}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CameraControls({ send, jointStates, limits }) {
|
||||
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 row = { display: 'flex', flexDirection: 'column', gap: 4 }
|
||||
const label = { fontSize: 12, color: '#888', display: 'flex', justifyContent: 'space-between' }
|
||||
|
||||
const panLimits = limits?.pan
|
||||
const tiltLimits = limits?.tilt
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 180 }}>
|
||||
<div className="control-panel" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: '#555', borderBottom: '1px solid #2a2a2a', paddingBottom: 4 }}>
|
||||
Camera
|
||||
</div>
|
||||
|
||||
{/* Pan */}
|
||||
<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) }}
|
||||
/>
|
||||
{panLimits
|
||||
? <input
|
||||
type="range"
|
||||
min={panLimits.lower} max={panLimits.upper} step={0.01}
|
||||
value={pan}
|
||||
style={{ accentColor: '#4ade80', width: '100%' }}
|
||||
onChange={(e) => { const v = +e.target.value; setPan(v); sendCmd(v, tilt) }}
|
||||
/>
|
||||
: <Waiting topic="/robot_description" />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Tilt */}
|
||||
<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) }}
|
||||
/>
|
||||
{tiltLimits
|
||||
? <input
|
||||
type="range"
|
||||
min={tiltLimits.lower} max={tiltLimits.upper} step={0.01}
|
||||
value={tilt}
|
||||
style={{ accentColor: '#4ade80', width: '100%' }}
|
||||
onChange={(e) => { const v = +e.target.value; setTilt(v); sendCmd(pan, v) }}
|
||||
/>
|
||||
: <Waiting topic="/robot_description" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled={!panLimits || !tiltLimits}
|
||||
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,
|
||||
opacity: (!panLimits || !tiltLimits) ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
Centre
|
||||
|
||||
@@ -46,7 +46,7 @@ export function LedControls({ send }) {
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 180 }}>
|
||||
<div className="control-panel" style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ fontSize: 12, color: '#555', borderBottom: '1px solid #2a2a2a', paddingBottom: 4 }}>
|
||||
LEDs
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,7 @@ export function VideoStream() {
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{ width: '100%', maxHeight: '60vh', background: '#000', display: 'block' }}
|
||||
style={{ background: '#000' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useWebSocket(onMessage) {
|
||||
const wsRef = useRef(null)
|
||||
const onMessageRef = useRef(onMessage)
|
||||
onMessageRef.current = onMessage
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket(`ws://${window.location.host}/ws`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => setConnected(true)
|
||||
ws.onclose = () => setConnected(false)
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try { onMessageRef.current(JSON.parse(e.data)) } catch {}
|
||||
}
|
||||
|
||||
let cleanedUp = false
|
||||
ws.onerror = (e) => {
|
||||
if (!cleanedUp) console.error('WebSocket error', e)
|
||||
@@ -23,9 +29,11 @@ export function useWebSocket(onMessage) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useCallback((data) => {
|
||||
const send = useCallback((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(data))
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { send, connected }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* ── App shell ────────────────────────────────────────────────────────────── */
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-video {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-video video {
|
||||
width: 100%;
|
||||
max-height: 55vh;
|
||||
background: #000;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Controls row ─────────────────────────────────────────────────────────── */
|
||||
.controls-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* ── Mobile ───────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.app-layout {
|
||||
height: auto;
|
||||
min-height: 100dvh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-video video {
|
||||
max-height: 52vw; /* landscape-ish crop on portrait phone */
|
||||
}
|
||||
|
||||
.controls-row {
|
||||
flex-direction: column;
|
||||
flex: none;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />)
|
||||
|
||||
Reference in New Issue
Block a user