Initial commit: Next.js UWB positioning webapp
- Complete Next.js 14 app with TypeScript and Tailwind CSS - ESP32 serial communication via API routes - Real-time UWB positioning visualization - Interactive 2D warehouse mapping with Canvas - Device connection interface with auto-detection - AT command parsing for UWBHelper library integration - Clean project structure with comprehensive documentation
This commit is contained in:
commit
fa75faa69d
20 changed files with 7600 additions and 0 deletions
193
components/DeviceConnection.tsx
Normal file
193
components/DeviceConnection.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { SerialPortInfo } from '@/lib/serial-utils'
|
||||
|
||||
interface DeviceConnectionProps {
|
||||
onConnectionChange: (connected: boolean) => void
|
||||
onDataReceived: (data: any) => void
|
||||
}
|
||||
|
||||
export default function DeviceConnection({
|
||||
onConnectionChange,
|
||||
onDataReceived
|
||||
}: DeviceConnectionProps) {
|
||||
const [availablePorts, setAvailablePorts] = useState<SerialPortInfo[]>([])
|
||||
const [selectedPort, setSelectedPort] = useState<string>('')
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load available serial ports
|
||||
useEffect(() => {
|
||||
loadPorts()
|
||||
}, [])
|
||||
|
||||
// Poll for data when connected
|
||||
useEffect(() => {
|
||||
if (!isConnected) return
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/uwb/data')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.rangeData || data.deviceData?.length > 0) {
|
||||
onDataReceived(data)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Data polling error:', error)
|
||||
}
|
||||
}, 1000) // Poll every second
|
||||
|
||||
return () => clearInterval(pollInterval)
|
||||
}, [isConnected, onDataReceived])
|
||||
|
||||
const loadPorts = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/serial/ports')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setAvailablePorts(data.esp32Ports || [])
|
||||
|
||||
// Auto-select first ESP32 port if available
|
||||
if (data.esp32Ports?.length > 0 && !selectedPort) {
|
||||
setSelectedPort(data.esp32Ports[0].path)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load ports:', error)
|
||||
setError('Failed to load available ports')
|
||||
}
|
||||
}
|
||||
|
||||
const connect = async () => {
|
||||
if (!selectedPort) return
|
||||
|
||||
setIsConnecting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/serial/connect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
port: selectedPort,
|
||||
baudRate: 115200
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setIsConnected(true)
|
||||
onConnectionChange(true)
|
||||
|
||||
// Send initial test command
|
||||
await sendCommand('AT?')
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.message || 'Connection failed')
|
||||
}
|
||||
} catch (error) {
|
||||
setError(`Connection error: ${error}`)
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = async () => {
|
||||
try {
|
||||
await fetch('/api/serial/connect', { method: 'DELETE' })
|
||||
setIsConnected(false)
|
||||
onConnectionChange(false)
|
||||
} catch (error) {
|
||||
console.error('Disconnect error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const sendCommand = async (command: string) => {
|
||||
try {
|
||||
await fetch('/api/uwb/data', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Command send error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Device Connection</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select ESP32 Device
|
||||
</label>
|
||||
<select
|
||||
value={selectedPort}
|
||||
onChange={(e) => setSelectedPort(e.target.value)}
|
||||
disabled={isConnected}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">Select a port...</option>
|
||||
{availablePorts.map(port => (
|
||||
<option key={port.path} value={port.path}>
|
||||
{port.path} - {port.manufacturer || 'Unknown'}
|
||||
{port.productId && ` (${port.productId})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{!isConnected ? (
|
||||
<button
|
||||
onClick={connect}
|
||||
disabled={!selectedPort || isConnecting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={disconnect}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={loadPorts}
|
||||
disabled={isConnecting}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 disabled:bg-gray-400"
|
||||
>
|
||||
Refresh Ports
|
||||
</button>
|
||||
|
||||
<div className={`flex items-center gap-2 ${isConnected ? 'text-green-600' : 'text-gray-400'}`}>
|
||||
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<span className="text-sm font-medium">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{availablePorts.length === 0 && (
|
||||
<p className="text-sm text-gray-500">
|
||||
No ESP32 devices detected. Make sure your device is connected and drivers are installed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
components/PositioningVisualizer.tsx
Normal file
233
components/PositioningVisualizer.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { AnchorPosition, TagPosition, DeviceData, RangeResult } from '@/lib/uwb-types'
|
||||
import DeviceConnection from './DeviceConnection'
|
||||
|
||||
interface VisualizerProps {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export default function PositioningVisualizer({
|
||||
width = 800,
|
||||
height = 600
|
||||
}: VisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [anchors, setAnchors] = useState<AnchorPosition[]>([])
|
||||
const [tagPosition, setTagPosition] = useState<TagPosition | null>(null)
|
||||
const [connectedDevices, setConnectedDevices] = useState<DeviceData[]>([])
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [lastDataUpdate, setLastDataUpdate] = useState<number>(0)
|
||||
|
||||
// Mock data for development
|
||||
useEffect(() => {
|
||||
const mockAnchors: AnchorPosition[] = [
|
||||
{ anchorId: 0, x: 0, y: 0, confidence: 0.9, valid: true },
|
||||
{ anchorId: 1, x: 10, y: 0, confidence: 0.8, valid: true },
|
||||
{ anchorId: 2, x: 10, y: 8, confidence: 0.9, valid: true },
|
||||
{ anchorId: 3, x: 0, y: 8, confidence: 0.7, valid: true },
|
||||
{ anchorId: 4, x: 5, y: 4, confidence: 0.8, valid: true },
|
||||
]
|
||||
|
||||
const mockTag: TagPosition = {
|
||||
x: 3.5,
|
||||
y: 2.1,
|
||||
timestamp: Date.now(),
|
||||
confidence: 0.85
|
||||
}
|
||||
|
||||
setAnchors(mockAnchors)
|
||||
setTagPosition(mockTag)
|
||||
}, [])
|
||||
|
||||
// Canvas rendering
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// Set up coordinate system (flip Y axis for standard cartesian)
|
||||
ctx.save()
|
||||
ctx.scale(1, -1)
|
||||
ctx.translate(0, -height)
|
||||
|
||||
// Scale to fit data (assuming 12m x 10m warehouse area)
|
||||
const scale = Math.min(width / 12, height / 10)
|
||||
ctx.scale(scale, scale)
|
||||
ctx.translate(1, 1) // Small offset from edges
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#e5e7eb'
|
||||
ctx.lineWidth = 1 / scale
|
||||
for (let i = 0; i <= 12; i += 2) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(i, 0)
|
||||
ctx.lineTo(i, 10)
|
||||
ctx.stroke()
|
||||
}
|
||||
for (let i = 0; i <= 10; i += 2) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, i)
|
||||
ctx.lineTo(12, i)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// Draw anchors
|
||||
anchors.forEach(anchor => {
|
||||
if (!anchor.valid) return
|
||||
|
||||
ctx.fillStyle = anchor.confidence > 0.8 ? '#10b981' : '#f59e0b'
|
||||
ctx.beginPath()
|
||||
ctx.arc(anchor.x, anchor.y, 0.3, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Anchor ID label
|
||||
ctx.save()
|
||||
ctx.scale(1, -1) // Flip text back
|
||||
ctx.fillStyle = '#374151'
|
||||
ctx.font = `${0.4}px Arial`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(
|
||||
anchor.anchorId.toString(),
|
||||
anchor.x,
|
||||
-anchor.y + 0.15
|
||||
)
|
||||
ctx.restore()
|
||||
})
|
||||
|
||||
// Draw tag position
|
||||
if (tagPosition) {
|
||||
ctx.fillStyle = '#3b82f6'
|
||||
ctx.beginPath()
|
||||
ctx.arc(tagPosition.x, tagPosition.y, 0.2, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
|
||||
// Tag confidence indicator
|
||||
ctx.strokeStyle = `rgba(59, 130, 246, ${tagPosition.confidence})`
|
||||
ctx.lineWidth = 3 / scale
|
||||
ctx.beginPath()
|
||||
ctx.arc(tagPosition.x, tagPosition.y, 0.5, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
}, [anchors, tagPosition, width, height])
|
||||
|
||||
const connectToDevice = async () => {
|
||||
try {
|
||||
// This will be implemented with actual serial connection
|
||||
setIsConnected(true)
|
||||
console.log('Connecting to UWB device...')
|
||||
} catch (error) {
|
||||
console.error('Failed to connect:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle real-time data updates
|
||||
const handleDataReceived = useCallback((data: any) => {
|
||||
setLastDataUpdate(Date.now())
|
||||
|
||||
if (data.rangeData) {
|
||||
// Process range data for positioning
|
||||
const rangeData: RangeResult = data.rangeData
|
||||
console.log('Range data received:', rangeData)
|
||||
|
||||
// Update device data from range results
|
||||
const deviceUpdates: DeviceData[] = []
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (rangeData.ranges[i] > 0 && rangeData.anchorIds[i] > 0) {
|
||||
deviceUpdates.push({
|
||||
deviceId: rangeData.anchorIds[i],
|
||||
distance: rangeData.ranges[i],
|
||||
rssi: rangeData.rssi[i],
|
||||
lastUpdate: Date.now(),
|
||||
active: true
|
||||
})
|
||||
}
|
||||
}
|
||||
setConnectedDevices(deviceUpdates)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnectionChange = useCallback((connected: boolean) => {
|
||||
setIsConnected(connected)
|
||||
if (!connected) {
|
||||
setConnectedDevices([])
|
||||
setLastDataUpdate(0)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Device Connection */}
|
||||
<DeviceConnection
|
||||
onConnectionChange={handleConnectionChange}
|
||||
onDataReceived={handleDataReceived}
|
||||
/>
|
||||
|
||||
{/* Status Panel */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">System Status</h2>
|
||||
|
||||
{/* Device Status */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Active Anchors:</span>
|
||||
<span className="ml-2">{anchors.filter(a => a.valid).length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Tag Position:</span>
|
||||
<span className="ml-2">
|
||||
{tagPosition ?
|
||||
`(${tagPosition.x.toFixed(1)}, ${tagPosition.y.toFixed(1)})` :
|
||||
'No data'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-600">Confidence:</span>
|
||||
<span className="ml-2">
|
||||
{tagPosition ?
|
||||
`${(tagPosition.confidence * 100).toFixed(0)}%` :
|
||||
'N/A'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualization Canvas */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4">Warehouse Map</h3>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
className="block"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center gap-6 text-sm text-gray-600">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||
<span>High Confidence Anchor</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
|
||||
<span>Low Confidence Anchor</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
<span>Mobile Tag</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue