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:
martin 2025-08-20 15:14:34 +02:00
commit fa75faa69d
20 changed files with 7600 additions and 0 deletions

7
.eslintrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off",
"react-hooks/exhaustive-deps": "warn"
}
}

33
.gitignore vendored Normal file
View file

@ -0,0 +1,33 @@
# Dependencies
/node_modules
/.pnp
.pnp.js
# Testing
/coverage
# Next.js
/.next/
/out/
# Production
/build
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
next-env.d.ts

171
CLAUDE.md Normal file
View file

@ -0,0 +1,171 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with the UWB Positioning Web Application.
## Project Overview
This is a **Next.js 14** web application for visualizing and controlling the ESP32-S3 Ultra-Wideband (UWB) indoor positioning system. It provides real-time positioning visualization and offline data analysis capabilities.
**Key Architecture**: React-based frontend with Node.js API routes, connects to ESP32 via USB serial, displays real-time positioning data with 2D warehouse visualization.
## Development Commands
### Setup & Development
```bash
npm install # Install dependencies
npm run dev # Start development server (http://localhost:3000)
npm run build # Build for production
npm run start # Start production server
npm run lint # Run ESLint
```
### API Testing
```bash
curl http://localhost:3000/api/serial/ports # List available serial ports
curl -X POST http://localhost:3000/api/serial/connect # Connect to device
curl http://localhost:3000/api/uwb/data # Get positioning data
```
## Core Architecture
### Frontend Structure
```
app/
├── page.tsx # Main dashboard page
├── layout.tsx # Root layout with metadata
└── globals.css # Global styles (Tailwind CSS)
components/
├── PositioningVisualizer.tsx # Main visualization component
└── DeviceConnection.tsx # Serial connection interface
```
### Backend API Routes
```
pages/api/
├── serial/
│ ├── ports.ts # GET: List ESP32 devices
│ └── connect.ts # POST/DELETE/GET: Device connection
└── uwb/
└── data.ts # GET/POST: UWB positioning data
```
### Data Flow Architecture
```
ESP32 (USB Serial) → API Routes → React Components → Canvas Visualization
WebSocket Updates → Real-time Display
```
## ESP32 Integration Context
### Hardware Communication
- **Serial Connection**: 115200 baud, USB to ESP32-S3
- **AT Command Protocol**: Direct integration with UWBHelper library
- **Device Detection**: Automatic ESP32 port identification (Silicon Labs CP210x, FTDI, CH340)
- **Real-time Data**: 1-second polling for AT+RANGE responses
### UWB Data Processing
- **Range Data Format**: `AT+RANGE=tid:X,timer:X,timerSys:X,mask:X,seq:X,range:(X,X,X...),rssi:(X,X,X...),ancid:(X,X,X...)`
- **Network Configuration**: ID 1234, 8 anchors max, 64 tags supported
- **Distance Units**: Centimeters from ESP32 → Meters in webapp
- **Coordinate System**: Standard Cartesian (0,0) at bottom-left
### TypeScript Data Structures
```typescript
// Core types from ESP32 project
interface DeviceData {
deviceId: number; // Anchor/Tag ID
distance: number; // Range in meters
rssi: number; // Signal strength
lastUpdate: number; // Timestamp
active: boolean; // Connection status
}
interface RangeResult {
tagId: number; // Mobile tag ID
ranges: number[8]; // Distances to 8 anchors
rssi: number[8]; // Signal strengths
anchorIds: number[8]; // Connected anchor IDs
// ... timing and sequence data
}
```
## Visualization Features
### 2D Warehouse Map
- **Canvas Rendering**: HTML5 Canvas with coordinate transformation
- **Grid System**: 2m grid spacing, 12m x 10m default warehouse
- **Real-time Updates**: Live anchor positions and tag movement
- **Visual Indicators**: Color-coded confidence levels (green=high, yellow=low)
### Interactive Elements
- **Device Connection**: Dropdown port selection with auto-detection
- **Status Dashboard**: Active anchors, tag position, confidence metrics
- **Real-time Data**: 1-second polling with automatic updates
## Configuration Constants
Key values matching ESP32 firmware:
- `MAX_ANCHORS: 8` - Maximum connected anchors per tag
- `UWB_TAG_COUNT: 64` - Network capacity
- `NETWORK_ID: 1234` - UWB network identifier
- `BAUD_RATE: 115200` - Serial communication speed
- `POLL_INTERVAL: 1000ms` - Real-time data update rate
## API Endpoints Reference
### Serial Communication
- `GET /api/serial/ports` - List available COM ports with ESP32 filtering
- `POST /api/serial/connect` - Connect to device: `{port: "COM3", baudRate: 115200}`
- `DELETE /api/serial/connect` - Disconnect from device
- `GET /api/serial/connect` - Get connection status
### UWB Data
- `GET /api/uwb/data` - Get latest positioning data
- `POST /api/uwb/data` - Send AT command: `{command: "AT+GETRPT?"}`
## Development Roadmap Context
This webapp implements **Phase 4** of the UWB positioning project (see main project ROADMAP.md):
- **Current**: Real-time visualization with ESP32 serial connection
- **Next**: Dual-file upload (raw_positioning.csv + anchor_coordinates.csv)
- **Future**: Path playback, session analysis, warehouse mapping tools
## Tech Stack Details
### Frontend
- **Framework**: Next.js 14 with App Router
- **Language**: TypeScript with strict mode
- **Styling**: Tailwind CSS 3.3+
- **UI Components**: Custom React components with Canvas rendering
- **State Management**: React hooks (useState, useEffect, useCallback)
### Backend
- **Runtime**: Node.js with Next.js API routes
- **Serial Communication**: SerialPort library for ESP32 connection
- **Data Processing**: Custom AT command parsing
- **WebSocket**: Real-time data streaming (planned)
### Development Tools
- **Linting**: ESLint with Next.js configuration
- **TypeScript**: Strict type checking with ESP32 data structures
- **Hot Reload**: Next.js development server with fast refresh
## Important Notes
- **Serial Port Access**: Requires native Node.js modules, client-side limitations handled via API routes
- **ESP32 Detection**: Automatic filtering for common ESP32 USB-to-Serial chips
- **Data Validation**: AT command format validation and error handling
- **Coordinate System**: Matches ESP32 firmware expectations (meters, Cartesian)
- **Real-time Performance**: 1-second polling optimized for warehouse-scale positioning
## File Upload Integration (Future)
The webapp is designed to support the dual-file approach from the main project:
- **raw_positioning.csv**: Tag distance measurements with timestamps
- **anchor_coordinates.csv**: Calibrated anchor positions from auto-positioning
- **Offline Analysis**: Calculate tag paths using anchor coordinates + raw distances
- **Session Playback**: Timeline scrubbing and path visualization
This webapp serves as the visualization frontend for the complete ESP32-S3 UWB positioning system, bridging real-time hardware data with interactive web-based analysis tools.

53
README.md Normal file
View file

@ -0,0 +1,53 @@
# UWB Positioning Web Application
Next.js web application for visualizing and analyzing UWB indoor positioning data.
## Features
- **Real-time Positioning**: Live visualization of tag positions via WebSocket
- **Data Analysis**: Upload and analyze positioning session files
- **Interactive Maps**: 2D warehouse visualization with anchor positions
- **Path Tracking**: Tag movement analysis and playback
## Quick Start
```bash
npm install
npm run dev
```
## Project Structure
```
uwb-webapp/
├── app/ # Next.js App Router pages
├── components/ # React components
├── lib/ # Utilities and configurations
├── pages/api/ # API routes
├── public/ # Static assets
└── utils/ # Helper functions
```
## Context from ESP32 Project
This webapp connects to the ESP32-S3 UWB positioning system:
- **Hardware**: Makerfabs MaUWB modules (ESP32-S3 + DW3000 UWB + OLED)
- **Network**: 8 anchors + 1 mobile tag (ID 1234, 6.8Mbps)
- **Data Flow**: Tag → USB → PC → WebApp
- **Files**: raw_positioning.csv + anchor_coordinates.csv
## API Endpoints
- `GET /api/serial` - List available serial ports
- `POST /api/connect` - Connect to UWB device
- `GET /api/data` - Get positioning data stream
- `POST /api/upload` - Upload CSV files for analysis
## Tech Stack
- **Framework**: Next.js 14 with App Router
- **Language**: TypeScript
- **Serial**: Node SerialPort for ESP32 communication
- **WebSocket**: Real-time data streaming
- **Visualization**: Canvas/SVG for 2D positioning display

9
app/globals.css Normal file
View file

@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
background-color: #f9fafb;
}

19
app/layout.tsx Normal file
View file

@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'UWB Positioning System',
description: 'ESP32-S3 Ultra-Wideband Indoor Positioning Visualization',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

22
app/page.tsx Normal file
View file

@ -0,0 +1,22 @@
import PositioningVisualizer from '@/components/PositioningVisualizer'
export default function HomePage() {
return (
<main className="min-h-screen">
<header className="bg-white shadow-sm border-b">
<div className="max-w-6xl mx-auto px-6 py-6">
<h1 className="text-3xl font-bold text-gray-900">
UWB Indoor Positioning System
</h1>
<p className="text-gray-600 mt-2">
ESP32-S3 Warehouse Positioning & Visualization
</p>
</div>
</header>
<div className="max-w-6xl mx-auto px-6 py-8">
<PositioningVisualizer />
</div>
</main>
)
}

View 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>
)
}

View 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>
)
}

71
lib/serial-utils.ts Normal file
View file

@ -0,0 +1,71 @@
// Utility functions for serial communication and UWB data processing
export interface SerialPortInfo {
path: string
manufacturer?: string
serialNumber?: string
pnpId?: string
locationId?: string
productId?: string
vendorId?: string
}
export class UWBDataProcessor {
private static instance: UWBDataProcessor
private dataBuffer: string = ''
static getInstance(): UWBDataProcessor {
if (!UWBDataProcessor.instance) {
UWBDataProcessor.instance = new UWBDataProcessor()
}
return UWBDataProcessor.instance
}
// Process incoming serial data stream
processDataStream(chunk: string): string[] {
this.dataBuffer += chunk
const lines: string[] = []
// Split on line endings
const parts = this.dataBuffer.split(/\r?\n/)
// Keep last incomplete line in buffer
this.dataBuffer = parts.pop() || ''
// Return complete lines
return parts.filter(line => line.trim().length > 0)
}
// Check if ESP32 device based on port info
static isESP32Device(port: SerialPortInfo): boolean {
const esp32Vendors = ['10C4', '1A86', '0403'] // Silicon Labs, QinHeng, FTDI
const esp32Manufacturers = ['Silicon Labs', 'FTDI', 'QinHeng Electronics']
return (
(port.vendorId && esp32Vendors.includes(port.vendorId)) ||
(port.manufacturer && esp32Manufacturers.some(m =>
port.manufacturer?.includes(m)
))
)
}
// Validate UWB command format
static validateUWBCommand(command: string): boolean {
const validCommands = [
'AT?', 'AT+GETVER?', 'AT+RESTART', 'AT+RESTORE', 'AT+SAVE',
'AT+SETCFG=', 'AT+GETCFG?', 'AT+SETANT=', 'AT+GETANT?',
'AT+SETCAP=', 'AT+GETCAP?', 'AT+SETRPT=', 'AT+GETRPT?',
'AT+SLEEP=', 'AT+SETPOW=', 'AT+GETPOW?', 'AT+DATA=',
'AT+RDATA', 'AT+SETPAN=', 'AT+GETPAN?'
]
return validCommands.some(cmd =>
command.startsWith(cmd) || command === cmd
)
}
// Clear data buffer
clearBuffer(): void {
this.dataBuffer = ''
}
}

67
lib/uwb-types.ts Normal file
View file

@ -0,0 +1,67 @@
// UWB Device Data Types (from ESP32 project context)
export interface DeviceData {
deviceId: number;
distance: number;
rssi: number;
lastUpdate: number;
active: boolean;
}
export interface AnchorPosition {
anchorId: number;
x: number;
y: number;
confidence: number;
valid: boolean;
}
export interface RangeResult {
tagId: number;
mask: number;
sequence: number;
ranges: number[]; // 8 elements
rssi: number[]; // 8 elements
anchorIds: number[]; // 8 elements
timer: number;
timerSys: number;
}
export interface TagPosition {
x: number;
y: number;
timestamp: number;
confidence: number;
}
// Configuration constants from ESP32 project
export const UWB_CONFIG = {
MAX_ANCHORS: 8,
UWB_TAG_COUNT: 64,
NETWORK_ID: 1234,
DEVICE_TIMEOUT: 5000,
BAUD_RATE: 115200,
DATA_RATE: 1, // 6.8Mbps
RANGE_FILTER: 1
} as const;
// Serial data parsing types
export interface SerialData {
type: 'range' | 'config' | 'status';
raw: string;
parsed?: RangeResult | DeviceData[];
timestamp: number;
}
// File upload types for offline analysis
export interface PositioningSession {
rawData: RangeResult[];
anchors: AnchorPosition[];
tagPath: TagPosition[];
sessionInfo: {
startTime: number;
endTime: number;
duration: number;
totalPoints: number;
};
}

12
next.config.js Normal file
View file

@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config) => {
// Handle serialport native bindings for server-side
config.externals.push({
'serialport': 'commonjs serialport'
});
return config;
}
}
module.exports = nextConfig

6377
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

41
package.json Normal file
View file

@ -0,0 +1,41 @@
{
"name": "uwb-positioning-webapp",
"version": "1.0.0",
"description": "Next.js web application for UWB indoor positioning system visualization and control",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"keywords": [
"uwb",
"positioning",
"indoor",
"warehouse",
"visualization",
"nextjs"
],
"author": "UWB Positioning Team",
"license": "MIT",
"dependencies": {
"@serialport/parser-readline": "^12.0.0",
"next": "^14.2.32",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"serialport": "^12.0.0",
"ws": "^8.14.0"
},
"devDependencies": {
"@types/node": "^20.8.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/ws": "^8.5.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.52.0",
"eslint-config-next": "14.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.3.0",
"typescript": "^5.2.0"
}
}

View file

@ -0,0 +1,91 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { SerialPort } from 'serialport'
import { ReadlineParser } from '@serialport/parser-readline'
// Global connection state
let serialConnection: SerialPort | null = null
let parser: ReadlineParser | null = null
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'POST') {
const { port, baudRate = 115200 } = req.body
if (!port) {
return res.status(400).json({ message: 'Port is required' })
}
try {
// Close existing connection
if (serialConnection?.isOpen) {
serialConnection.close()
}
// Create new connection
serialConnection = new SerialPort({
path: port,
baudRate: baudRate,
autoOpen: false
})
// Set up parser for line-based data
parser = serialConnection.pipe(new ReadlineParser({ delimiter: '\r\n' }))
// Open connection
await new Promise<void>((resolve, reject) => {
serialConnection!.open((err) => {
if (err) reject(err)
else resolve()
})
})
res.status(200).json({
message: 'Connected successfully',
port,
baudRate,
isOpen: serialConnection.isOpen
})
} catch (error) {
console.error('Serial connection error:', error)
res.status(500).json({
message: 'Failed to connect',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
else if (req.method === 'DELETE') {
// Disconnect
try {
if (serialConnection?.isOpen) {
serialConnection.close()
serialConnection = null
parser = null
}
res.status(200).json({ message: 'Disconnected successfully' })
} catch (error) {
res.status(500).json({
message: 'Failed to disconnect',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
else if (req.method === 'GET') {
// Get connection status
res.status(200).json({
connected: serialConnection?.isOpen || false,
port: serialConnection?.path || null
})
}
else {
res.status(405).json({ message: 'Method not allowed' })
}
}
export { serialConnection, parser }

37
pages/api/serial/ports.ts Normal file
View file

@ -0,0 +1,37 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { SerialPort } from 'serialport'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method not allowed' })
}
try {
const ports = await SerialPort.list()
// Filter for ESP32 devices (common VID/PID patterns)
const esp32Ports = ports.filter(port =>
port.vendorId === '10C4' || // Silicon Labs CP210x
port.vendorId === '1A86' || // QinHeng Electronics CH340
port.vendorId === '0403' || // FTDI
port.manufacturer?.includes('Silicon Labs') ||
port.manufacturer?.includes('FTDI') ||
port.productId === 'EA60' // CP2102
)
res.status(200).json({
allPorts: ports,
esp32Ports,
count: esp32Ports.length
})
} catch (error) {
console.error('Error listing serial ports:', error)
res.status(500).json({
message: 'Failed to list serial ports',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}

112
pages/api/uwb/data.ts Normal file
View file

@ -0,0 +1,112 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { serialConnection, parser } from '../serial/connect'
import { RangeResult, DeviceData } from '@/lib/uwb-types'
// Data storage for real-time updates
let latestRangeData: RangeResult | null = null
let latestDeviceData: DeviceData[] = []
// Parse UWB range data (from ESP32 UWBHelper parseRangeData format)
function parseUWBRangeData(data: string): RangeResult | DeviceData[] | null {
if (!data.startsWith("AT+RANGE=")) {
return null
}
// Parse tid (Tag ID)
const tidMatch = data.match(/tid:(\d+)/)
if (!tidMatch) return null
const tagId = parseInt(tidMatch[1])
// Parse detailed range data format
const maskMatch = data.match(/mask:(\d+)/)
const seqMatch = data.match(/seq:(\d+)/)
const timerMatch = data.match(/timer:(\d+)/)
const timerSysMatch = data.match(/timerSys:(\d+)/)
// Parse range data
const rangeMatch = data.match(/range:\(([^)]+)\)/)
const rssiMatch = data.match(/rssi:\(([^)]+)\)/)
const ancidMatch = data.match(/ancid:\(([^)]+)\)/)
if (rangeMatch && rssiMatch) {
const ranges = rangeMatch[1].split(',').map(r => parseFloat(r) / 100) // Convert cm to meters
const rssi = rssiMatch[1].split(',').map(r => parseFloat(r))
const anchorIds = ancidMatch ? ancidMatch[1].split(',').map(a => parseInt(a)) : Array(8).fill(0)
const result: RangeResult = {
tagId,
mask: maskMatch ? parseInt(maskMatch[1]) : 0,
sequence: seqMatch ? parseInt(seqMatch[1]) : 0,
ranges: ranges.slice(0, 8).concat(Array(8 - ranges.length).fill(0)),
rssi: rssi.slice(0, 8).concat(Array(8 - rssi.length).fill(0)),
anchorIds: anchorIds.slice(0, 8).concat(Array(8 - anchorIds.length).fill(0)),
timer: timerMatch ? parseInt(timerMatch[1]) : 0,
timerSys: timerSysMatch ? parseInt(timerSysMatch[1]) : 0
}
latestRangeData = result
return result
}
return null
}
// Set up data listener when connection is established
if (parser) {
parser.on('data', (data: string) => {
const parsed = parseUWBRangeData(data.trim())
if (parsed) {
console.log('UWB Data received:', parsed)
}
})
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === 'GET') {
if (!serialConnection?.isOpen) {
return res.status(400).json({
message: 'Serial connection not established'
})
}
// Return latest data
res.status(200).json({
rangeData: latestRangeData,
deviceData: latestDeviceData,
timestamp: Date.now()
})
}
else if (req.method === 'POST') {
// Send command to UWB device
const { command } = req.body
if (!serialConnection?.isOpen) {
return res.status(400).json({
message: 'Serial connection not established'
})
}
if (!command) {
return res.status(400).json({ message: 'Command is required' })
}
try {
serialConnection.write(command + '\r\n')
res.status(200).json({ message: 'Command sent', command })
} catch (error) {
res.status(500).json({
message: 'Failed to send command',
error: error instanceof Error ? error.message : 'Unknown error'
})
}
}
else {
res.status(405).json({ message: 'Method not allowed' })
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

18
tailwind.config.js Normal file
View file

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}

28
tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}