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
7
.eslintrc.json
Normal file
7
.eslintrc.json
Normal 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
33
.gitignore
vendored
Normal 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
171
CLAUDE.md
Normal 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
53
README.md
Normal 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
9
app/globals.css
Normal 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
19
app/layout.tsx
Normal 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
22
app/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
lib/serial-utils.ts
Normal file
71
lib/serial-utils.ts
Normal 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
67
lib/uwb-types.ts
Normal 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
12
next.config.js
Normal 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
6377
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
41
package.json
Normal file
41
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
91
pages/api/serial/connect.ts
Normal file
91
pages/api/serial/connect.ts
Normal 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
37
pages/api/serial/ports.ts
Normal 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
112
pages/api/uwb/data.ts
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
18
tailwind.config.js
Normal file
18
tailwind.config.js
Normal 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
28
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue