Spaces:
Running
Running
feat: separated node & web
Browse files- README.md +214 -0
- docs/conventions.md +6 -2
- index.html +22 -3
- public/vite.svg +0 -1
- src/cli/index.ts +1 -1
- src/counter.ts +0 -9
- src/lerobot/{find_port.ts → node/find_port.ts} +0 -0
- src/lerobot/web/find_port.ts +292 -0
- src/main.ts +202 -22
- src/style.css +0 -96
- src/typescript.svg +0 -1
- src/web_interface.css +238 -0
README.md
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🤖 lerobot.js
|
| 2 |
+
|
| 3 |
+
**State-of-the-art AI for real-world robotics in JavaScript/TypeScript**
|
| 4 |
+
|
| 5 |
+
A faithful TypeScript/JavaScript port of [Hugging Face's lerobot](https://github.com/huggingface/lerobot), bringing cutting-edge robotics AI to the JavaScript ecosystem with **zero Python dependencies**.
|
| 6 |
+
|
| 7 |
+
## ✨ Features
|
| 8 |
+
|
| 9 |
+
- 🔌 **USB Port Detection**: Find robot arm serial ports in Node.js CLI and browser
|
| 10 |
+
- 🌐 **Universal**: Works in Node.js, browsers, and Edge devices
|
| 11 |
+
- 🎯 **Python Faithful**: Identical UX and messaging to original lerobot
|
| 12 |
+
- 📱 **WebSerial**: Browser-native serial port access (Chrome/Edge 89+)
|
| 13 |
+
- 🚀 **Zero Dependencies**: No Python runtime required
|
| 14 |
+
- 📦 **Lightweight**: ~2.3KB package size
|
| 15 |
+
|
| 16 |
+
## 🚀 Quick Start
|
| 17 |
+
|
| 18 |
+
### CLI Usage (Node.js)
|
| 19 |
+
|
| 20 |
+
```bash
|
| 21 |
+
# Use directly without installation
|
| 22 |
+
npx lerobot find-port
|
| 23 |
+
|
| 24 |
+
# Or install globally
|
| 25 |
+
npm install -g lerobot
|
| 26 |
+
lerobot find-port
|
| 27 |
+
```
|
| 28 |
+
|
| 29 |
+
### Browser Usage
|
| 30 |
+
|
| 31 |
+
1. **Development**:
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
git clone https://github.com/timpietrusky/lerobot.js
|
| 35 |
+
cd lerobot.js
|
| 36 |
+
pnpm install
|
| 37 |
+
pnpm run dev
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
2. **Visit**: `http://localhost:5173`
|
| 41 |
+
|
| 42 |
+
3. **Use the interface**:
|
| 43 |
+
- Click "Show Available Ports" to see connected devices
|
| 44 |
+
- Click "Find MotorsBus Port" for guided port detection
|
| 45 |
+
|
| 46 |
+
## 📖 Documentation
|
| 47 |
+
|
| 48 |
+
### Find USB Ports
|
| 49 |
+
|
| 50 |
+
Identify which USB ports your robot arms are connected to - essential for SO-100 setup.
|
| 51 |
+
|
| 52 |
+
#### CLI Example
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
$ npx lerobot find-port
|
| 56 |
+
|
| 57 |
+
Finding all available ports for the MotorsBus.
|
| 58 |
+
Ports before disconnecting: ['COM3', 'COM4']
|
| 59 |
+
Remove the USB cable from your MotorsBus and press Enter when done.
|
| 60 |
+
|
| 61 |
+
The port of this MotorsBus is 'COM3'
|
| 62 |
+
Reconnect the USB cable.
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
#### Browser Example
|
| 66 |
+
|
| 67 |
+
1. Click "Show Available Ports" → Browser asks for permission
|
| 68 |
+
2. Grant access to your serial devices
|
| 69 |
+
3. See list of connected ports
|
| 70 |
+
4. Use "Find MotorsBus Port" for detection workflow
|
| 71 |
+
|
| 72 |
+
### Platform Support
|
| 73 |
+
|
| 74 |
+
| Platform | Method | Requirements |
|
| 75 |
+
| ----------- | ----------------------- | ----------------------------------- |
|
| 76 |
+
| **Node.js** | `npx lerobot find-port` | Node.js 18+, Windows/macOS/Linux |
|
| 77 |
+
| **Browser** | Web interface | Chrome/Edge 89+, HTTPS or localhost |
|
| 78 |
+
| **Mobile** | Browser | Chrome Android 105+ |
|
| 79 |
+
|
| 80 |
+
### Browser Compatibility
|
| 81 |
+
|
| 82 |
+
The browser version uses the [WebSerial API](https://web.dev/serial/):
|
| 83 |
+
|
| 84 |
+
- ✅ **Chrome/Edge 89+** (Desktop)
|
| 85 |
+
- ✅ **Chrome Android 105+** (Mobile)
|
| 86 |
+
- ✅ **HTTPS** or localhost required
|
| 87 |
+
- ❌ Firefox/Safari (WebSerial not supported)
|
| 88 |
+
|
| 89 |
+
## 🛠️ Development
|
| 90 |
+
|
| 91 |
+
### Setup
|
| 92 |
+
|
| 93 |
+
```bash
|
| 94 |
+
git clone https://github.com/timpietrusky/lerobot.js
|
| 95 |
+
cd lerobot.js
|
| 96 |
+
pnpm install
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
### Scripts
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
# Development server (browser)
|
| 103 |
+
pnpm run dev
|
| 104 |
+
|
| 105 |
+
# Build CLI for Node.js
|
| 106 |
+
pnpm run build:cli
|
| 107 |
+
|
| 108 |
+
# Build web interface
|
| 109 |
+
pnpm run build:web
|
| 110 |
+
|
| 111 |
+
# Test CLI locally
|
| 112 |
+
pnpm run cli:find-port
|
| 113 |
+
|
| 114 |
+
# Build everything
|
| 115 |
+
pnpm run build
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
### Project Structure
|
| 119 |
+
|
| 120 |
+
```
|
| 121 |
+
src/
|
| 122 |
+
├── lerobot/
|
| 123 |
+
│ ├── node/
|
| 124 |
+
│ │ └── find_port.ts # Node.js implementation
|
| 125 |
+
│ └── web/
|
| 126 |
+
│ └── find_port.ts # Browser implementation (WebSerial)
|
| 127 |
+
├── cli/
|
| 128 |
+
│ └── index.ts # CLI interface
|
| 129 |
+
├── main.ts # Web interface
|
| 130 |
+
└── web_interface.css # UI styles
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
## 🎯 Design Principles
|
| 134 |
+
|
| 135 |
+
### 1. Python lerobot Faithfulness
|
| 136 |
+
|
| 137 |
+
- **Identical commands**: `npx lerobot find-port` ↔ `python -m lerobot.find_port`
|
| 138 |
+
- **Same terminology**: "MotorsBus", not "robot arms"
|
| 139 |
+
- **Matching output**: Error messages and workflows identical
|
| 140 |
+
- **Familiar UX**: Python lerobot users feel immediately at home
|
| 141 |
+
|
| 142 |
+
### 2. Platform Abstraction
|
| 143 |
+
|
| 144 |
+
- **Universal core**: Shared robotics logic
|
| 145 |
+
- **Adaptive UX**: CLI prompts vs. browser modals
|
| 146 |
+
- **Progressive enhancement**: Works everywhere, enhanced on capable platforms
|
| 147 |
+
|
| 148 |
+
### 3. Zero Dependencies
|
| 149 |
+
|
| 150 |
+
- **Node.js**: Only uses built-in modules + lightweight `serialport`
|
| 151 |
+
- **Browser**: Native WebSerial API, no external libraries
|
| 152 |
+
- **Deployment**: Single package, no Python runtime needed
|
| 153 |
+
|
| 154 |
+
## 🔧 Technical Details
|
| 155 |
+
|
| 156 |
+
### WebSerial API
|
| 157 |
+
|
| 158 |
+
The browser implementation leverages modern web APIs:
|
| 159 |
+
|
| 160 |
+
```typescript
|
| 161 |
+
// Request permission to access serial ports
|
| 162 |
+
await navigator.serial.requestPort();
|
| 163 |
+
|
| 164 |
+
// List granted ports
|
| 165 |
+
const ports = await navigator.serial.getPorts();
|
| 166 |
+
|
| 167 |
+
// Detect disconnected devices
|
| 168 |
+
const removedPorts = portsBefore.filter(/* ... */);
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
### CLI Implementation
|
| 172 |
+
|
| 173 |
+
Node.js version uses the `serialport` library for cross-platform compatibility:
|
| 174 |
+
|
| 175 |
+
```typescript
|
| 176 |
+
import { SerialPort } from "serialport";
|
| 177 |
+
|
| 178 |
+
// Cross-platform port detection
|
| 179 |
+
const ports = await SerialPort.list();
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
## 🗺️ Roadmap
|
| 183 |
+
|
| 184 |
+
- [x] **Phase 1**: USB port detection (CLI + Browser)
|
| 185 |
+
- [ ] **Phase 2**: Motor communication and setup
|
| 186 |
+
- [ ] **Phase 3**: Robot calibration tools
|
| 187 |
+
- [ ] **Phase 4**: Dataset management and visualization
|
| 188 |
+
- [ ] **Phase 5**: Policy inference (ONNX.js)
|
| 189 |
+
- [ ] **Phase 6**: Training infrastructure
|
| 190 |
+
|
| 191 |
+
## 🤝 Contributing
|
| 192 |
+
|
| 193 |
+
We welcome contributions! This project follows the principle of **Python lerobot faithfulness** - all features should maintain identical UX to the original.
|
| 194 |
+
|
| 195 |
+
### Guidelines
|
| 196 |
+
|
| 197 |
+
1. Check Python lerobot implementation first
|
| 198 |
+
2. Maintain identical command structure and messaging
|
| 199 |
+
3. Follow snake_case file naming convention
|
| 200 |
+
4. Test on both Node.js and browser platforms
|
| 201 |
+
|
| 202 |
+
## 📄 License
|
| 203 |
+
|
| 204 |
+
Apache 2.0 - Same as original lerobot
|
| 205 |
+
|
| 206 |
+
## 🙏 Acknowledgments
|
| 207 |
+
|
| 208 |
+
- [Hugging Face lerobot team](https://github.com/huggingface/lerobot) for the original Python implementation
|
| 209 |
+
- [WebSerial API](https://web.dev/serial/) for browser-native hardware access
|
| 210 |
+
- [serialport](https://github.com/serialport/node-serialport) for Node.js cross-platform support
|
| 211 |
+
|
| 212 |
+
---
|
| 213 |
+
|
| 214 |
+
**Built with ❤️ for the robotics community**
|
docs/conventions.md
CHANGED
|
@@ -8,6 +8,10 @@
|
|
| 8 |
|
| 9 |
> Lower the barrier to entry for robotics by making cutting-edge robotic AI accessible through JavaScript, the world's most widely used programming language.
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
## Project Goals
|
| 12 |
|
| 13 |
### Primary Objectives
|
|
@@ -63,8 +67,8 @@ lerobot/
|
|
| 63 |
│ ├── devices/ # Hardware device interfaces
|
| 64 |
│ └── utils/ # Shared utilities
|
| 65 |
├── core/ # Core robotics primitives
|
| 66 |
-
├──
|
| 67 |
-
└──
|
| 68 |
```
|
| 69 |
|
| 70 |
### 3. Platform Abstraction
|
|
|
|
| 8 |
|
| 9 |
> Lower the barrier to entry for robotics by making cutting-edge robotic AI accessible through JavaScript, the world's most widely used programming language.
|
| 10 |
|
| 11 |
+
## Core Rules
|
| 12 |
+
|
| 13 |
+
- you never start the dev server, because it is already running
|
| 14 |
+
|
| 15 |
## Project Goals
|
| 16 |
|
| 17 |
### Primary Objectives
|
|
|
|
| 67 |
│ ├── devices/ # Hardware device interfaces
|
| 68 |
│ └── utils/ # Shared utilities
|
| 69 |
├── core/ # Core robotics primitives
|
| 70 |
+
├── node/ # Node.js-specific implementations
|
| 71 |
+
└── web/ # Browser-specific implementations
|
| 72 |
```
|
| 73 |
|
| 74 |
### 3. Platform Abstraction
|
index.html
CHANGED
|
@@ -1,13 +1,32 @@
|
|
| 1 |
-
<!
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="app"></div>
|
| 11 |
<script type="module" src="/src/main.ts"></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
</body>
|
| 13 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>🤖 lerobot.js - Robotics in the Browser</title>
|
| 7 |
+
<meta
|
| 8 |
+
name="description"
|
| 9 |
+
content="State-of-the-art AI for real-world robotics in JavaScript/TypeScript"
|
| 10 |
+
/>
|
| 11 |
+
<style>
|
| 12 |
+
/* Prevent flash of unstyled content */
|
| 13 |
+
body {
|
| 14 |
+
opacity: 0;
|
| 15 |
+
}
|
| 16 |
+
body.loaded {
|
| 17 |
+
opacity: 1;
|
| 18 |
+
transition: opacity 0.3s ease;
|
| 19 |
+
}
|
| 20 |
+
</style>
|
| 21 |
</head>
|
| 22 |
<body>
|
| 23 |
<div id="app"></div>
|
| 24 |
<script type="module" src="/src/main.ts"></script>
|
| 25 |
+
<script>
|
| 26 |
+
// Add loaded class when page is ready
|
| 27 |
+
window.addEventListener("load", () => {
|
| 28 |
+
document.body.classList.add("loaded");
|
| 29 |
+
});
|
| 30 |
+
</script>
|
| 31 |
</body>
|
| 32 |
</html>
|
public/vite.svg
DELETED
src/cli/index.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
| 7 |
* Maintains compatibility with Python lerobot command structure
|
| 8 |
*/
|
| 9 |
|
| 10 |
-
import { findPort } from "../lerobot/find_port.js";
|
| 11 |
|
| 12 |
/**
|
| 13 |
* Show usage information
|
|
|
|
| 7 |
* Maintains compatibility with Python lerobot command structure
|
| 8 |
*/
|
| 9 |
|
| 10 |
+
import { findPort } from "../lerobot/node/find_port.js";
|
| 11 |
|
| 12 |
/**
|
| 13 |
* Show usage information
|
src/counter.ts
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
export function setupCounter(element: HTMLButtonElement) {
|
| 2 |
-
let counter = 0
|
| 3 |
-
const setCounter = (count: number) => {
|
| 4 |
-
counter = count
|
| 5 |
-
element.innerHTML = `count is ${counter}`
|
| 6 |
-
}
|
| 7 |
-
element.addEventListener('click', () => setCounter(counter + 1))
|
| 8 |
-
setCounter(0)
|
| 9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lerobot/{find_port.ts → node/find_port.ts}
RENAMED
|
File without changes
|
src/lerobot/web/find_port.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Browser implementation of find_port using WebSerial API
|
| 3 |
+
*
|
| 4 |
+
* Provides the same functionality as the Node.js version but adapted for browser environment
|
| 5 |
+
* Uses WebSerial API for serial port detection and user interaction through DOM
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Type definitions for WebSerial API (not yet in all TypeScript libs)
|
| 10 |
+
*/
|
| 11 |
+
interface SerialPort {
|
| 12 |
+
readonly readable: ReadableStream;
|
| 13 |
+
readonly writable: WritableStream;
|
| 14 |
+
getInfo(): SerialPortInfo;
|
| 15 |
+
open(options: SerialOptions): Promise<void>;
|
| 16 |
+
close(): Promise<void>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
interface SerialPortInfo {
|
| 20 |
+
usbVendorId?: number;
|
| 21 |
+
usbProductId?: number;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface SerialOptions {
|
| 25 |
+
baudRate: number;
|
| 26 |
+
dataBits?: number;
|
| 27 |
+
stopBits?: number;
|
| 28 |
+
parity?: "none" | "even" | "odd";
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
interface Serial extends EventTarget {
|
| 32 |
+
getPorts(): Promise<SerialPort[]>;
|
| 33 |
+
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
interface SerialPortRequestOptions {
|
| 37 |
+
filters?: SerialPortFilter[];
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
interface SerialPortFilter {
|
| 41 |
+
usbVendorId?: number;
|
| 42 |
+
usbProductId?: number;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
declare global {
|
| 46 |
+
interface Navigator {
|
| 47 |
+
serial: Serial;
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Check if WebSerial API is available
|
| 53 |
+
*/
|
| 54 |
+
function isWebSerialSupported(): boolean {
|
| 55 |
+
return "serial" in navigator;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Get all available serial ports (requires user permission)
|
| 60 |
+
* Browser equivalent of Node.js findAvailablePorts()
|
| 61 |
+
*/
|
| 62 |
+
async function findAvailablePortsWeb(): Promise<SerialPort[]> {
|
| 63 |
+
if (!isWebSerialSupported()) {
|
| 64 |
+
throw new Error(
|
| 65 |
+
"WebSerial API not supported. Please use Chrome/Edge 89+ or Chrome Android 105+"
|
| 66 |
+
);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
try {
|
| 70 |
+
return await navigator.serial.getPorts();
|
| 71 |
+
} catch (error) {
|
| 72 |
+
throw new Error(
|
| 73 |
+
`Failed to get serial ports: ${
|
| 74 |
+
error instanceof Error ? error.message : error
|
| 75 |
+
}`
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/**
|
| 81 |
+
* Format port info for display
|
| 82 |
+
* Mimics the Node.js port listing format
|
| 83 |
+
*/
|
| 84 |
+
function formatPortInfo(ports: SerialPort[]): string[] {
|
| 85 |
+
return ports.map((port, index) => {
|
| 86 |
+
const info = port.getInfo();
|
| 87 |
+
if (info.usbVendorId && info.usbProductId) {
|
| 88 |
+
return `Port ${index + 1} (USB:${info.usbVendorId}:${info.usbProductId})`;
|
| 89 |
+
}
|
| 90 |
+
return `Port ${index + 1}`;
|
| 91 |
+
});
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Sleep for specified milliseconds
|
| 96 |
+
* Same as Node.js version
|
| 97 |
+
*/
|
| 98 |
+
function sleep(ms: number): Promise<void> {
|
| 99 |
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Wait for user interaction (button click or similar)
|
| 104 |
+
* Browser equivalent of Node.js readline input()
|
| 105 |
+
*/
|
| 106 |
+
function waitForUserAction(message: string): Promise<void> {
|
| 107 |
+
return new Promise((resolve) => {
|
| 108 |
+
const modal = document.createElement("div");
|
| 109 |
+
modal.style.cssText = `
|
| 110 |
+
position: fixed;
|
| 111 |
+
top: 0;
|
| 112 |
+
left: 0;
|
| 113 |
+
width: 100%;
|
| 114 |
+
height: 100%;
|
| 115 |
+
background: rgba(0,0,0,0.5);
|
| 116 |
+
display: flex;
|
| 117 |
+
align-items: center;
|
| 118 |
+
justify-content: center;
|
| 119 |
+
z-index: 1000;
|
| 120 |
+
`;
|
| 121 |
+
|
| 122 |
+
const dialog = document.createElement("div");
|
| 123 |
+
dialog.style.cssText = `
|
| 124 |
+
background: white;
|
| 125 |
+
padding: 2rem;
|
| 126 |
+
border-radius: 8px;
|
| 127 |
+
text-align: center;
|
| 128 |
+
max-width: 500px;
|
| 129 |
+
margin: 1rem;
|
| 130 |
+
`;
|
| 131 |
+
|
| 132 |
+
dialog.innerHTML = `
|
| 133 |
+
<h3>Port Detection</h3>
|
| 134 |
+
<p style="margin: 1rem 0;">${message}</p>
|
| 135 |
+
<button id="continue-btn" style="
|
| 136 |
+
background: #3498db;
|
| 137 |
+
color: white;
|
| 138 |
+
border: none;
|
| 139 |
+
padding: 12px 24px;
|
| 140 |
+
border-radius: 6px;
|
| 141 |
+
cursor: pointer;
|
| 142 |
+
font-size: 1rem;
|
| 143 |
+
">Continue</button>
|
| 144 |
+
`;
|
| 145 |
+
|
| 146 |
+
modal.appendChild(dialog);
|
| 147 |
+
document.body.appendChild(modal);
|
| 148 |
+
|
| 149 |
+
const continueBtn = dialog.querySelector(
|
| 150 |
+
"#continue-btn"
|
| 151 |
+
) as HTMLButtonElement;
|
| 152 |
+
continueBtn.addEventListener("click", () => {
|
| 153 |
+
document.body.removeChild(modal);
|
| 154 |
+
resolve();
|
| 155 |
+
});
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
/**
|
| 160 |
+
* Request permission to access serial ports
|
| 161 |
+
*/
|
| 162 |
+
async function requestSerialPermission(
|
| 163 |
+
logger: (message: string) => void
|
| 164 |
+
): Promise<void> {
|
| 165 |
+
logger("Requesting permission to access serial ports...");
|
| 166 |
+
logger(
|
| 167 |
+
'Please select a serial device when prompted, or click "Cancel" if no devices are connected yet.'
|
| 168 |
+
);
|
| 169 |
+
|
| 170 |
+
try {
|
| 171 |
+
// This will show the browser's serial port selection dialog
|
| 172 |
+
await navigator.serial.requestPort();
|
| 173 |
+
logger("✅ Permission granted to access serial ports.");
|
| 174 |
+
} catch (error) {
|
| 175 |
+
// User cancelled the dialog - this is OK, they might not have devices connected yet
|
| 176 |
+
console.log("Permission dialog cancelled:", error);
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
/**
|
| 181 |
+
* Main find port function for browser
|
| 182 |
+
* Maintains identical UX and messaging to Node.js version
|
| 183 |
+
*/
|
| 184 |
+
export async function findPortWeb(
|
| 185 |
+
logger: (message: string) => void
|
| 186 |
+
): Promise<void> {
|
| 187 |
+
logger("Finding all available ports for the MotorsBus.");
|
| 188 |
+
|
| 189 |
+
// Check WebSerial support
|
| 190 |
+
if (!isWebSerialSupported()) {
|
| 191 |
+
throw new Error(
|
| 192 |
+
"WebSerial API not supported. Please use Chrome/Edge 89+ with HTTPS or localhost."
|
| 193 |
+
);
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
// Get initial ports (check what we already have access to)
|
| 197 |
+
let portsBefore: SerialPort[];
|
| 198 |
+
try {
|
| 199 |
+
portsBefore = await findAvailablePortsWeb();
|
| 200 |
+
} catch (error) {
|
| 201 |
+
throw new Error(
|
| 202 |
+
`Failed to get serial ports: ${
|
| 203 |
+
error instanceof Error ? error.message : error
|
| 204 |
+
}`
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// If no ports are available, request permission
|
| 209 |
+
if (portsBefore.length === 0) {
|
| 210 |
+
logger(
|
| 211 |
+
"⚠️ No serial ports available. Requesting permission to access devices..."
|
| 212 |
+
);
|
| 213 |
+
await requestSerialPermission(logger);
|
| 214 |
+
|
| 215 |
+
// Try again after permission request
|
| 216 |
+
portsBefore = await findAvailablePortsWeb();
|
| 217 |
+
|
| 218 |
+
if (portsBefore.length === 0) {
|
| 219 |
+
throw new Error(
|
| 220 |
+
'No ports detected. Please connect your devices, use "Show Available Ports" first, or check browser compatibility.'
|
| 221 |
+
);
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// Show current ports
|
| 226 |
+
const portsBeforeFormatted = formatPortInfo(portsBefore);
|
| 227 |
+
logger(
|
| 228 |
+
`Ports before disconnecting: [${portsBeforeFormatted
|
| 229 |
+
.map((p) => `'${p}'`)
|
| 230 |
+
.join(", ")}]`
|
| 231 |
+
);
|
| 232 |
+
|
| 233 |
+
// Ask user to disconnect device
|
| 234 |
+
logger(
|
| 235 |
+
"Remove the USB cable from your MotorsBus and press Continue when done."
|
| 236 |
+
);
|
| 237 |
+
await waitForUserAction(
|
| 238 |
+
"Remove the USB cable from your MotorsBus and press Continue when done."
|
| 239 |
+
);
|
| 240 |
+
|
| 241 |
+
// Allow some time for port to be released (equivalent to Python's time.sleep(0.5))
|
| 242 |
+
await sleep(500);
|
| 243 |
+
|
| 244 |
+
// Get ports after disconnection
|
| 245 |
+
const portsAfter = await findAvailablePortsWeb();
|
| 246 |
+
const portsAfterFormatted = formatPortInfo(portsAfter);
|
| 247 |
+
logger(
|
| 248 |
+
`Ports after disconnecting: [${portsAfterFormatted
|
| 249 |
+
.map((p) => `'${p}'`)
|
| 250 |
+
.join(", ")}]`
|
| 251 |
+
);
|
| 252 |
+
|
| 253 |
+
// Find the difference by comparing port objects directly
|
| 254 |
+
// This handles cases where multiple devices have the same vendor/product ID
|
| 255 |
+
const removedPorts = portsBefore.filter((portBefore) => {
|
| 256 |
+
return !portsAfter.includes(portBefore);
|
| 257 |
+
});
|
| 258 |
+
|
| 259 |
+
// If object comparison fails (e.g., browser creates new objects), fall back to count-based detection
|
| 260 |
+
if (removedPorts.length === 0 && portsBefore.length > portsAfter.length) {
|
| 261 |
+
const countDifference = portsBefore.length - portsAfter.length;
|
| 262 |
+
if (countDifference === 1) {
|
| 263 |
+
logger(`The port of this MotorsBus is one of the disconnected devices.`);
|
| 264 |
+
logger(
|
| 265 |
+
"Note: Exact port identification not possible with identical devices."
|
| 266 |
+
);
|
| 267 |
+
logger("Reconnect the USB cable.");
|
| 268 |
+
return;
|
| 269 |
+
} else {
|
| 270 |
+
logger(`${countDifference} ports were removed, but expected exactly 1.`);
|
| 271 |
+
logger("Please disconnect only one device and try again.");
|
| 272 |
+
return;
|
| 273 |
+
}
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
if (removedPorts.length === 1) {
|
| 277 |
+
const port = formatPortInfo(removedPorts)[0];
|
| 278 |
+
logger(`The port of this MotorsBus is '${port}'`);
|
| 279 |
+
logger("Reconnect the USB cable.");
|
| 280 |
+
} else if (removedPorts.length === 0) {
|
| 281 |
+
logger("No difference found, did you remove the USB cable?");
|
| 282 |
+
logger("Please try again: disconnect one device and click Continue.");
|
| 283 |
+
return;
|
| 284 |
+
} else {
|
| 285 |
+
const portNames = formatPortInfo(removedPorts);
|
| 286 |
+
throw new Error(
|
| 287 |
+
`Could not detect the port. More than one port was found (${JSON.stringify(
|
| 288 |
+
portNames
|
| 289 |
+
)}).`
|
| 290 |
+
);
|
| 291 |
+
}
|
| 292 |
+
}
|
src/main.ts
CHANGED
|
@@ -1,24 +1,204 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
<
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
</div>
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* lerobot.js Web Interface
|
| 3 |
+
*
|
| 4 |
+
* Browser-based interface for lerobot functionality
|
| 5 |
+
* Provides the same find-port functionality as the CLI but in the browser
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import "./web_interface.css";
|
| 9 |
+
import { findPortWeb } from "./lerobot/web/find_port.js";
|
| 10 |
+
|
| 11 |
+
document.querySelector<HTMLDivElement>("#app")!.innerHTML = `
|
| 12 |
+
<div class="lerobot-app">
|
| 13 |
+
<header class="lerobot-header">
|
| 14 |
+
<h1>🤖 lerobot.js</h1>
|
| 15 |
+
<p>use your robot in the web & node with an api similar to LeRobot in python</p>
|
| 16 |
+
</header>
|
| 17 |
+
|
| 18 |
+
<main class="lerobot-main">
|
| 19 |
+
<section class="tool-section">
|
| 20 |
+
<h2>🔍 Find USB Ports</h2>
|
| 21 |
+
<p>Identify which USB ports your robot arms are connected to</p>
|
| 22 |
+
<div class="button-group">
|
| 23 |
+
<button id="show-ports-btn" class="secondary-btn" style="display: none;">Show Available Ports</button>
|
| 24 |
+
<button id="manage-devices-btn" class="secondary-btn">Manage Devices</button>
|
| 25 |
+
<button id="find-port-btn" class="primary-btn">Find MotorsBus Port</button>
|
| 26 |
+
</div>
|
| 27 |
+
<div id="info-box" class="info-box" style="display: none;">
|
| 28 |
+
<span class="info-icon">💡</span>
|
| 29 |
+
<span class="info-text">Use "Manage Devices" to pair additional devices or "Find MotorsBus Port" to start detection.</span>
|
| 30 |
+
</div>
|
| 31 |
+
<div id="port-results" class="results-area"></div>
|
| 32 |
+
</section>
|
| 33 |
+
|
| 34 |
+
<section class="info-section" id="compatibility-section" style="display: none;">
|
| 35 |
+
<h3>Browser Compatibility Issue</h3>
|
| 36 |
+
<p>Your browser doesn't support the <a href="https://web.dev/serial/" target="_blank">WebSerial API</a>. Please use:</p>
|
| 37 |
+
<ul>
|
| 38 |
+
<li>Chrome/Edge 89+ or Chrome Android 105+</li>
|
| 39 |
+
<li>HTTPS connection (or localhost for development)</li>
|
| 40 |
+
</ul>
|
| 41 |
+
<p>Alternatively, use the <strong>CLI version</strong>: <code>npx lerobot find-port</code></p>
|
| 42 |
+
</section>
|
| 43 |
+
</main>
|
| 44 |
</div>
|
| 45 |
+
`;
|
| 46 |
+
|
| 47 |
+
// Set up button functionality
|
| 48 |
+
const showPortsBtn =
|
| 49 |
+
document.querySelector<HTMLButtonElement>("#show-ports-btn")!;
|
| 50 |
+
const manageDevicesBtn = document.querySelector<HTMLButtonElement>(
|
| 51 |
+
"#manage-devices-btn"
|
| 52 |
+
)!;
|
| 53 |
+
const findPortBtn =
|
| 54 |
+
document.querySelector<HTMLButtonElement>("#find-port-btn")!;
|
| 55 |
+
const resultsArea = document.querySelector<HTMLDivElement>("#port-results")!;
|
| 56 |
+
const infoBox = document.querySelector<HTMLDivElement>("#info-box")!;
|
| 57 |
+
|
| 58 |
+
// Function to display paired devices
|
| 59 |
+
async function displayPairedDevices() {
|
| 60 |
+
try {
|
| 61 |
+
// Check WebSerial support
|
| 62 |
+
if (!("serial" in navigator)) {
|
| 63 |
+
resultsArea.innerHTML =
|
| 64 |
+
'<p class="error">WebSerial API not supported. Please use Chrome/Edge 89+ with HTTPS or localhost.</p>';
|
| 65 |
+
infoBox.style.display = "none";
|
| 66 |
+
showPortsBtn.style.display = "inline-block";
|
| 67 |
+
return;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
// Check what ports we already have access to
|
| 71 |
+
const ports = await navigator.serial.getPorts();
|
| 72 |
+
|
| 73 |
+
if (ports.length > 0) {
|
| 74 |
+
// We have paired devices, show them
|
| 75 |
+
resultsArea.innerHTML = `<p class="success">Found ${ports.length} paired device(s):</p>`;
|
| 76 |
+
ports.forEach((port, index) => {
|
| 77 |
+
const info = port.getInfo();
|
| 78 |
+
if (info.usbVendorId && info.usbProductId) {
|
| 79 |
+
resultsArea.innerHTML += `<p class="log">Port ${index + 1}: USB:${
|
| 80 |
+
info.usbVendorId
|
| 81 |
+
}:${info.usbProductId}</p>`;
|
| 82 |
+
} else {
|
| 83 |
+
resultsArea.innerHTML += `<p class="log">Port ${
|
| 84 |
+
index + 1
|
| 85 |
+
}: Serial device</p>`;
|
| 86 |
+
}
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
// Show the info box with guidance
|
| 90 |
+
infoBox.style.display = "flex";
|
| 91 |
+
|
| 92 |
+
// Hide show ports button since we have devices
|
| 93 |
+
showPortsBtn.style.display = "none";
|
| 94 |
+
} else {
|
| 95 |
+
// No devices paired, show helpful message
|
| 96 |
+
resultsArea.innerHTML =
|
| 97 |
+
'<p class="log">No paired devices found. Click "Show Available Ports" to get started.</p>';
|
| 98 |
+
|
| 99 |
+
// Hide the info box since we don't have devices
|
| 100 |
+
infoBox.style.display = "none";
|
| 101 |
+
|
| 102 |
+
// Show the show ports button since we need it
|
| 103 |
+
showPortsBtn.style.display = "inline-block";
|
| 104 |
+
}
|
| 105 |
+
} catch (error) {
|
| 106 |
+
resultsArea.innerHTML += `<p class="error">Error checking devices: ${
|
| 107 |
+
error instanceof Error ? error.message : error
|
| 108 |
+
}</p>`;
|
| 109 |
+
infoBox.style.display = "none";
|
| 110 |
+
showPortsBtn.style.display = "inline-block";
|
| 111 |
+
}
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Check browser compatibility and show warning if needed
|
| 115 |
+
function checkBrowserCompatibility() {
|
| 116 |
+
const compatibilitySection = document.querySelector(
|
| 117 |
+
"#compatibility-section"
|
| 118 |
+
) as HTMLElement;
|
| 119 |
+
|
| 120 |
+
if (!("serial" in navigator)) {
|
| 121 |
+
// Browser doesn't support WebSerial API, show compatibility warning
|
| 122 |
+
compatibilitySection.style.display = "block";
|
| 123 |
+
} else {
|
| 124 |
+
// Browser supports WebSerial API, hide compatibility section
|
| 125 |
+
compatibilitySection.style.display = "none";
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Check for paired devices and browser compatibility on page load
|
| 130 |
+
checkBrowserCompatibility();
|
| 131 |
+
displayPairedDevices();
|
| 132 |
+
|
| 133 |
+
// Show available ports button (only for when no devices are paired)
|
| 134 |
+
showPortsBtn.addEventListener("click", async () => {
|
| 135 |
+
try {
|
| 136 |
+
showPortsBtn.disabled = true;
|
| 137 |
+
showPortsBtn.textContent = "Pairing devices...";
|
| 138 |
+
resultsArea.innerHTML =
|
| 139 |
+
'<p class="status">Requesting permission to access serial ports...</p>';
|
| 140 |
+
|
| 141 |
+
try {
|
| 142 |
+
await navigator.serial.requestPort();
|
| 143 |
+
// Refresh the display
|
| 144 |
+
await displayPairedDevices();
|
| 145 |
+
} catch (permissionError) {
|
| 146 |
+
console.log("Permission dialog cancelled:", permissionError);
|
| 147 |
+
}
|
| 148 |
+
} catch (error) {
|
| 149 |
+
resultsArea.innerHTML += `<p class="error">Error: ${
|
| 150 |
+
error instanceof Error ? error.message : error
|
| 151 |
+
}</p>`;
|
| 152 |
+
} finally {
|
| 153 |
+
showPortsBtn.disabled = false;
|
| 154 |
+
showPortsBtn.textContent = "Show Available Ports";
|
| 155 |
+
}
|
| 156 |
+
});
|
| 157 |
+
|
| 158 |
+
// Manage devices button (always available)
|
| 159 |
+
manageDevicesBtn.addEventListener("click", async () => {
|
| 160 |
+
try {
|
| 161 |
+
manageDevicesBtn.disabled = true;
|
| 162 |
+
manageDevicesBtn.textContent = "Managing...";
|
| 163 |
+
|
| 164 |
+
// Always show the permission dialog to pair new devices
|
| 165 |
+
try {
|
| 166 |
+
await navigator.serial.requestPort();
|
| 167 |
+
// Refresh the display to show updated device list
|
| 168 |
+
await displayPairedDevices();
|
| 169 |
+
resultsArea.innerHTML +=
|
| 170 |
+
'<p class="success">Device pairing completed. Updated device list above.</p>';
|
| 171 |
+
} catch (permissionError) {
|
| 172 |
+
console.log("Permission dialog cancelled:", permissionError);
|
| 173 |
+
}
|
| 174 |
+
} catch (error) {
|
| 175 |
+
resultsArea.innerHTML += `<p class="error">Error: ${
|
| 176 |
+
error instanceof Error ? error.message : error
|
| 177 |
+
}</p>`;
|
| 178 |
+
} finally {
|
| 179 |
+
manageDevicesBtn.disabled = false;
|
| 180 |
+
manageDevicesBtn.textContent = "Manage Devices";
|
| 181 |
+
resultsArea.scrollTop = resultsArea.scrollHeight;
|
| 182 |
+
}
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
// Find port button
|
| 186 |
+
findPortBtn.addEventListener("click", async () => {
|
| 187 |
+
try {
|
| 188 |
+
findPortBtn.disabled = true;
|
| 189 |
+
findPortBtn.textContent = "Finding ports...";
|
| 190 |
+
resultsArea.innerHTML = '<p class="status">Starting port detection...</p>';
|
| 191 |
|
| 192 |
+
await findPortWeb((message: string) => {
|
| 193 |
+
resultsArea.innerHTML += `<p class="log">${message}</p>`;
|
| 194 |
+
resultsArea.scrollTop = resultsArea.scrollHeight;
|
| 195 |
+
});
|
| 196 |
+
} catch (error) {
|
| 197 |
+
resultsArea.innerHTML += `<p class="error">Error: ${
|
| 198 |
+
error instanceof Error ? error.message : error
|
| 199 |
+
}</p>`;
|
| 200 |
+
} finally {
|
| 201 |
+
findPortBtn.disabled = false;
|
| 202 |
+
findPortBtn.textContent = "Find MotorsBus Port";
|
| 203 |
+
}
|
| 204 |
+
});
|
src/style.css
DELETED
|
@@ -1,96 +0,0 @@
|
|
| 1 |
-
:root {
|
| 2 |
-
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
| 3 |
-
line-height: 1.5;
|
| 4 |
-
font-weight: 400;
|
| 5 |
-
|
| 6 |
-
color-scheme: light dark;
|
| 7 |
-
color: rgba(255, 255, 255, 0.87);
|
| 8 |
-
background-color: #242424;
|
| 9 |
-
|
| 10 |
-
font-synthesis: none;
|
| 11 |
-
text-rendering: optimizeLegibility;
|
| 12 |
-
-webkit-font-smoothing: antialiased;
|
| 13 |
-
-moz-osx-font-smoothing: grayscale;
|
| 14 |
-
}
|
| 15 |
-
|
| 16 |
-
a {
|
| 17 |
-
font-weight: 500;
|
| 18 |
-
color: #646cff;
|
| 19 |
-
text-decoration: inherit;
|
| 20 |
-
}
|
| 21 |
-
a:hover {
|
| 22 |
-
color: #535bf2;
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
body {
|
| 26 |
-
margin: 0;
|
| 27 |
-
display: flex;
|
| 28 |
-
place-items: center;
|
| 29 |
-
min-width: 320px;
|
| 30 |
-
min-height: 100vh;
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
h1 {
|
| 34 |
-
font-size: 3.2em;
|
| 35 |
-
line-height: 1.1;
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
#app {
|
| 39 |
-
max-width: 1280px;
|
| 40 |
-
margin: 0 auto;
|
| 41 |
-
padding: 2rem;
|
| 42 |
-
text-align: center;
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
.logo {
|
| 46 |
-
height: 6em;
|
| 47 |
-
padding: 1.5em;
|
| 48 |
-
will-change: filter;
|
| 49 |
-
transition: filter 300ms;
|
| 50 |
-
}
|
| 51 |
-
.logo:hover {
|
| 52 |
-
filter: drop-shadow(0 0 2em #646cffaa);
|
| 53 |
-
}
|
| 54 |
-
.logo.vanilla:hover {
|
| 55 |
-
filter: drop-shadow(0 0 2em #3178c6aa);
|
| 56 |
-
}
|
| 57 |
-
|
| 58 |
-
.card {
|
| 59 |
-
padding: 2em;
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
.read-the-docs {
|
| 63 |
-
color: #888;
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
button {
|
| 67 |
-
border-radius: 8px;
|
| 68 |
-
border: 1px solid transparent;
|
| 69 |
-
padding: 0.6em 1.2em;
|
| 70 |
-
font-size: 1em;
|
| 71 |
-
font-weight: 500;
|
| 72 |
-
font-family: inherit;
|
| 73 |
-
background-color: #1a1a1a;
|
| 74 |
-
cursor: pointer;
|
| 75 |
-
transition: border-color 0.25s;
|
| 76 |
-
}
|
| 77 |
-
button:hover {
|
| 78 |
-
border-color: #646cff;
|
| 79 |
-
}
|
| 80 |
-
button:focus,
|
| 81 |
-
button:focus-visible {
|
| 82 |
-
outline: 4px auto -webkit-focus-ring-color;
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
@media (prefers-color-scheme: light) {
|
| 86 |
-
:root {
|
| 87 |
-
color: #213547;
|
| 88 |
-
background-color: #ffffff;
|
| 89 |
-
}
|
| 90 |
-
a:hover {
|
| 91 |
-
color: #747bff;
|
| 92 |
-
}
|
| 93 |
-
button {
|
| 94 |
-
background-color: #f9f9f9;
|
| 95 |
-
}
|
| 96 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/typescript.svg
DELETED
src/web_interface.css
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* lerobot.js Web Interface Styles */
|
| 2 |
+
|
| 3 |
+
* {
|
| 4 |
+
margin: 0;
|
| 5 |
+
padding: 0;
|
| 6 |
+
box-sizing: border-box;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
body {
|
| 10 |
+
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
| 11 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 12 |
+
min-height: 100vh;
|
| 13 |
+
display: flex;
|
| 14 |
+
align-items: center;
|
| 15 |
+
justify-content: center;
|
| 16 |
+
color: #333;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
.lerobot-app {
|
| 20 |
+
background: white;
|
| 21 |
+
border-radius: 12px;
|
| 22 |
+
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
| 23 |
+
max-width: 800px;
|
| 24 |
+
width: 90%;
|
| 25 |
+
padding: 2rem;
|
| 26 |
+
margin: 2rem;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.lerobot-header {
|
| 30 |
+
text-align: center;
|
| 31 |
+
margin-bottom: 2rem;
|
| 32 |
+
padding-bottom: 1rem;
|
| 33 |
+
border-bottom: 1px solid #eee;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.lerobot-header h1 {
|
| 37 |
+
font-size: 2.5rem;
|
| 38 |
+
color: #2c3e50;
|
| 39 |
+
margin-bottom: 0.5rem;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.lerobot-header p {
|
| 43 |
+
color: #7f8c8d;
|
| 44 |
+
font-size: 1.1rem;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.lerobot-main {
|
| 48 |
+
display: grid;
|
| 49 |
+
gap: 2rem;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.tool-section {
|
| 53 |
+
background: #f8f9fa;
|
| 54 |
+
padding: 1.5rem;
|
| 55 |
+
border-radius: 8px;
|
| 56 |
+
border-left: 4px solid #3498db;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.tool-section h2 {
|
| 60 |
+
color: #2c3e50;
|
| 61 |
+
margin-bottom: 0.5rem;
|
| 62 |
+
font-size: 1.4rem;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.tool-section p {
|
| 66 |
+
color: #7f8c8d;
|
| 67 |
+
margin-bottom: 1rem;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.button-group {
|
| 71 |
+
display: flex;
|
| 72 |
+
gap: 0.5rem;
|
| 73 |
+
margin-bottom: 1rem;
|
| 74 |
+
flex-wrap: wrap;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.info-box {
|
| 78 |
+
background: #e8f4fd;
|
| 79 |
+
border: 1px solid #3498db;
|
| 80 |
+
border-radius: 6px;
|
| 81 |
+
padding: 12px 16px;
|
| 82 |
+
margin-bottom: 1rem;
|
| 83 |
+
display: flex;
|
| 84 |
+
align-items: center;
|
| 85 |
+
gap: 8px;
|
| 86 |
+
color: #2c3e50;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.info-icon {
|
| 90 |
+
font-size: 1.1rem;
|
| 91 |
+
flex-shrink: 0;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
.info-text {
|
| 95 |
+
font-size: 0.95rem;
|
| 96 |
+
line-height: 1.4;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.primary-btn,
|
| 100 |
+
.secondary-btn {
|
| 101 |
+
border: none;
|
| 102 |
+
padding: 12px 24px;
|
| 103 |
+
border-radius: 6px;
|
| 104 |
+
font-size: 1rem;
|
| 105 |
+
font-weight: 600;
|
| 106 |
+
cursor: pointer;
|
| 107 |
+
transition: all 0.3s ease;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.primary-btn {
|
| 111 |
+
background: linear-gradient(135deg, #3498db, #2980b9);
|
| 112 |
+
color: white;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.secondary-btn {
|
| 116 |
+
background: linear-gradient(135deg, #95a5a6, #7f8c8d);
|
| 117 |
+
color: white;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.primary-btn:hover:not(:disabled) {
|
| 121 |
+
transform: translateY(-2px);
|
| 122 |
+
box-shadow: 0 6px 12px rgba(52, 152, 219, 0.3);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.secondary-btn:hover:not(:disabled) {
|
| 126 |
+
transform: translateY(-2px);
|
| 127 |
+
box-shadow: 0 6px 12px rgba(149, 165, 166, 0.3);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.primary-btn:disabled,
|
| 131 |
+
.secondary-btn:disabled {
|
| 132 |
+
opacity: 0.6;
|
| 133 |
+
cursor: not-allowed;
|
| 134 |
+
transform: none;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.results-area {
|
| 138 |
+
background: #2c3e50;
|
| 139 |
+
color: #ecf0f1;
|
| 140 |
+
padding: 1rem;
|
| 141 |
+
border-radius: 6px;
|
| 142 |
+
font-family: "Fira Code", "Courier New", monospace;
|
| 143 |
+
font-size: 0.9rem;
|
| 144 |
+
line-height: 1.4;
|
| 145 |
+
max-height: 300px;
|
| 146 |
+
overflow-y: auto;
|
| 147 |
+
min-height: 100px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.results-area:empty::before {
|
| 151 |
+
content: "Results will appear here...";
|
| 152 |
+
color: #7f8c8d;
|
| 153 |
+
font-style: italic;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.results-area .log {
|
| 157 |
+
margin: 0.25rem 0;
|
| 158 |
+
color: #bdc3c7;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.results-area .status {
|
| 162 |
+
margin: 0.25rem 0;
|
| 163 |
+
color: #3498db;
|
| 164 |
+
font-weight: 600;
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
.results-area .error {
|
| 168 |
+
margin: 0.25rem 0;
|
| 169 |
+
color: #e74c3c;
|
| 170 |
+
font-weight: 600;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.results-area .success {
|
| 174 |
+
margin: 0.25rem 0;
|
| 175 |
+
color: #27ae60;
|
| 176 |
+
font-weight: 600;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.info-section {
|
| 180 |
+
background: #fff3cd;
|
| 181 |
+
padding: 1.5rem;
|
| 182 |
+
border-radius: 8px;
|
| 183 |
+
border-left: 4px solid #ffc107;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.info-section h3 {
|
| 187 |
+
color: #856404;
|
| 188 |
+
margin-bottom: 0.5rem;
|
| 189 |
+
font-size: 1.2rem;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.info-section p {
|
| 193 |
+
color: #856404;
|
| 194 |
+
margin-bottom: 0.5rem;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.info-section ul {
|
| 198 |
+
color: #856404;
|
| 199 |
+
margin-left: 1.5rem;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.info-section li {
|
| 203 |
+
margin-bottom: 0.25rem;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
.info-section a {
|
| 207 |
+
color: #0056b3;
|
| 208 |
+
text-decoration: none;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.info-section a:hover {
|
| 212 |
+
text-decoration: underline;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.info-section code {
|
| 216 |
+
background: #f5f5f5;
|
| 217 |
+
padding: 2px 6px;
|
| 218 |
+
border-radius: 3px;
|
| 219 |
+
font-family: "Fira Code", "Courier New", monospace;
|
| 220 |
+
font-size: 0.9em;
|
| 221 |
+
color: #333;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/* Responsive design */
|
| 225 |
+
@media (max-width: 600px) {
|
| 226 |
+
.lerobot-app {
|
| 227 |
+
margin: 1rem;
|
| 228 |
+
padding: 1.5rem;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.lerobot-header h1 {
|
| 232 |
+
font-size: 2rem;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
.lerobot-header p {
|
| 236 |
+
font-size: 1rem;
|
| 237 |
+
}
|
| 238 |
+
}
|